feat(skills): the first Skill-Use measurement, and the three defects it found
Scored on the paper's three axes against two real missions on the local
stack. docs/SKILL-USE-BASELINE.md has the numbers, the method, and the
limits.
Trigger is reported as NOT OBSERVABLE, never zero
The paper measures progressive disclosure: the agent sees a name and
description and must retrieve the body, and that retrieval is the Trigger
event. We inline full bodies, because mission claws run on claude_cli which
cannot surface a tool call — there is nothing to retrieve with. So the
agent never reaches for a skill, it simply holds one.
Scoring that zero would report a delivery-model property as an agent
failure, which is the same confusion that kept 55 empty bindings invisible
for months. The verdict type carries NotObservable(reason) as a distinct
case from Fail for exactly this.
Compliance is checked by running the REAL task_card_parser rather than a
copy of its rules — a second implementation would drift, and then the score
would pass while the mission loop still stalled. Skills without a
machine-checkable consequence score not_applicable rather than a guess.
WHAT THE MEASUREMENT FOUND
1. The prompt format made its own record unparseable. Skills were
introduced with `## <name>` and skill bodies are markdown full of `##`
headings, so run 1 scored "Sizing heuristic" and "The output shape" —
subheadings inside decompose-int-items — as skills with no catalogue
row. Now an unambiguous `--- SKILL: <name> ---` marker, with both
writers sharing one renderer so the reader cannot drift from the writer.
2. A prompt was recorded that was never sent. My own Phase 1 work recorded
the phase prompt at the dispatch fork, before the tier was chosen — and
the container tier does not send that text, it sends the bare task and
appends skills per turn. Every container mission logged a `solo` prompt
that reached no agent. A provenance record of something that did not
happen is worse than no record: it is the wrong answer, delivered
confidently. Recording now happens inside each tier, with a test that
every launcher records the prompt it actually sends.
3. int-xx-marker-protocol documents a marker the platform never
implemented. PLAN_COMPLETE is in the skill's ladder and task_card_parser
has no such kind and never has, so an agent following the skill exactly
emits a marker that is silently ignored. Observed live: run 2's planner
emitted `PLAN_COMPLETE: INT-01..02`, which is also the range form — on
the kinds that ARE parsed that yields the id `INT-01..02`, a task card
for an item that does not exist while the two real items stay open.
This is a skill/implementation mismatch, not an agent failure, and it is
exactly what the measurement exists to find: the agent did what it was
told and what it was told was wrong. Both shapes now score as failures.
The reconciliation — implement PLAN_COMPLETE or drop it from the skill —
is left as a decision rather than guessed at.
The boot log now shows what the plan asked for: 53 skills, 11 templates,
every one `N role skills bound` with NO unresolved clause. Live missions
confirm per-role delivery — the planner receives decompose-int-items, the
coder receives write-rust-current-edition.
GET /api/missions/{id}/skill-use exposes the scores, and says in its
payload whether an empty result means "nothing delivered" or "the evidence
was reaped" — those have very different causes and must not look the same.
n = 2. No spread is reported because two runs cannot establish one, and the
document says so rather than letting the number be quoted as a baseline it
is not.
Full workspace suite green: 106 binaries.
Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
co-authored by
Claude Opus 5
parent
769e002bb3
commit
91a6b4e304
@@ -53,6 +53,7 @@ pub mod runtime_provision;
|
|||||||
pub mod security_scan;
|
pub mod security_scan;
|
||||||
pub mod session_executor;
|
pub mod session_executor;
|
||||||
pub mod skill_self_authoring;
|
pub mod skill_self_authoring;
|
||||||
|
pub mod skill_use;
|
||||||
pub mod skills_loader;
|
pub mod skills_loader;
|
||||||
pub mod subscription;
|
pub mod subscription;
|
||||||
pub mod swarm;
|
pub mod swarm;
|
||||||
@@ -590,6 +591,10 @@ pub fn router(state: AppState) -> Router {
|
|||||||
"/api/missions/{id}/phases/{phase_id}/evaluations",
|
"/api/missions/{id}/phases/{phase_id}/evaluations",
|
||||||
get(routes::missions::list_phase_evaluations),
|
get(routes::missions::list_phase_evaluations),
|
||||||
)
|
)
|
||||||
|
.route(
|
||||||
|
"/api/missions/{id}/skill-use",
|
||||||
|
get(routes::missions::skill_use),
|
||||||
|
)
|
||||||
.route(
|
.route(
|
||||||
"/api/missions/{id}/teams",
|
"/api/missions/{id}/teams",
|
||||||
get(routes::missions::list_teams),
|
get(routes::missions::list_teams),
|
||||||
|
|||||||
@@ -235,6 +235,39 @@ mod skill_delivery_wiring_tests {
|
|||||||
/// one — and that substitution is a one-word edit that would silently
|
/// one — and that substitution is a one-word edit that would silently
|
||||||
/// return all three tiers to delivering no skill, with every test still
|
/// return all three tiers to delivering no skill, with every test still
|
||||||
/// green. Same reasoning as `mission_events::the_cap_is_enforced_in_one_statement`.
|
/// green. Same reasoning as `mission_events::the_cap_is_enforced_in_one_statement`.
|
||||||
|
/// The prompt must be recorded by the tier that SENDS it.
|
||||||
|
///
|
||||||
|
/// Recording at the dispatch fork wrote a phase prompt for container
|
||||||
|
/// missions too, and the container tier does not send that text — it sends
|
||||||
|
/// the bare task and appends skills per turn. Observed on a live mission:
|
||||||
|
/// three prompt.composed rows, one of which was never given to anything.
|
||||||
|
#[test]
|
||||||
|
fn every_solo_tier_records_the_prompt_it_actually_sends() {
|
||||||
|
let src = include_str!("phase_runner.rs");
|
||||||
|
let anchor = format!("async fn {}(", "record_phase_prompt");
|
||||||
|
assert!(
|
||||||
|
src.contains(&anchor),
|
||||||
|
"the per-tier recorder is gone; a fork-level record would log \
|
||||||
|
prompts that were never sent"
|
||||||
|
);
|
||||||
|
for launcher in [
|
||||||
|
"launch_composed_microvm_phase",
|
||||||
|
"launch_microvm_phase",
|
||||||
|
"launch_direct_session",
|
||||||
|
] {
|
||||||
|
let body = src
|
||||||
|
.split(&format!("async fn {launcher}("))
|
||||||
|
.nth(1)
|
||||||
|
.and_then(|s| s.split("\nasync fn ").next())
|
||||||
|
.unwrap_or_else(|| panic!("{launcher} not found"));
|
||||||
|
assert!(
|
||||||
|
body.contains("record_phase_prompt("),
|
||||||
|
"{launcher} runs a prompt it never records — that phase becomes \
|
||||||
|
unexplainable after the fact"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn the_three_solo_tiers_are_handed_the_skill_bearing_task() {
|
fn the_three_solo_tiers_are_handed_the_skill_bearing_task() {
|
||||||
let src = include_str!("phase_runner.rs");
|
let src = include_str!("phase_runner.rs");
|
||||||
@@ -944,20 +977,6 @@ async fn launch_phase(
|
|||||||
None => task.clone(),
|
None => task.clone(),
|
||||||
};
|
};
|
||||||
|
|
||||||
// One prompt per phase on these tiers, because the phase IS one session.
|
|
||||||
// `topology_runs.task` holds a copy for two of them, but not for the
|
|
||||||
// composed path and not with the skills appended — and a provenance record
|
|
||||||
// that exists on some tiers is one that cannot be queried uniformly.
|
|
||||||
{
|
|
||||||
let mut ev = crate::mission_events::MissionEvent::new(
|
|
||||||
mission_id,
|
|
||||||
crate::mission_events::PROMPT_COMPOSED,
|
|
||||||
);
|
|
||||||
ev.phase_id = Some(phase_id);
|
|
||||||
ev.target = Some(kind.to_string());
|
|
||||||
ev.detail = serde_json::json!({ "text": task_with_skills, "tier": "solo" });
|
|
||||||
crate::mission_events::record(pool, ev).await;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Direct-session executor: run the whole phase as ONE `claude -p` session
|
// Direct-session executor: run the whole phase as ONE `claude -p` session
|
||||||
// against the mission checkout, instead of driving turns through ZeroClaw.
|
// against the mission checkout, instead of driving turns through ZeroClaw.
|
||||||
@@ -1223,6 +1242,7 @@ async fn launch_composed_microvm_phase(
|
|||||||
teams: Vec<(Uuid, serde_json::Value)>,
|
teams: Vec<(Uuid, serde_json::Value)>,
|
||||||
purposes: &[&str],
|
purposes: &[&str],
|
||||||
) -> Result<(), String> {
|
) -> Result<(), String> {
|
||||||
|
record_phase_prompt(pool, mission_id, phase_id, "composed_microvm", task).await;
|
||||||
sqlx::query(
|
sqlx::query(
|
||||||
"DELETE FROM topology_runs
|
"DELETE FROM topology_runs
|
||||||
WHERE mission_phase_id = $1 AND status IN ('failed', 'cancelled')",
|
WHERE mission_phase_id = $1 AND status IN ('failed', 'cancelled')",
|
||||||
@@ -1363,6 +1383,7 @@ async fn launch_microvm_phase(
|
|||||||
// `VmPhase::has_repo`.
|
// `VmPhase::has_repo`.
|
||||||
has_repo: bool,
|
has_repo: bool,
|
||||||
) -> Result<(), String> {
|
) -> Result<(), String> {
|
||||||
|
record_phase_prompt(pool, mission_id, phase_id, "microvm", task).await;
|
||||||
sqlx::query(
|
sqlx::query(
|
||||||
"DELETE FROM topology_runs
|
"DELETE FROM topology_runs
|
||||||
WHERE mission_phase_id = $1 AND status IN ('failed', 'cancelled')",
|
WHERE mission_phase_id = $1 AND status IN ('failed', 'cancelled')",
|
||||||
@@ -1596,6 +1617,7 @@ async fn launch_direct_session(
|
|||||||
iteration: i32,
|
iteration: i32,
|
||||||
task: &str,
|
task: &str,
|
||||||
) -> Result<(), String> {
|
) -> Result<(), String> {
|
||||||
|
record_phase_prompt(pool, mission_id, phase_id, "session", task).await;
|
||||||
sqlx::query(
|
sqlx::query(
|
||||||
"DELETE FROM topology_runs
|
"DELETE FROM topology_runs
|
||||||
WHERE mission_phase_id = $1 AND status IN ('failed', 'cancelled')",
|
WHERE mission_phase_id = $1 AND status IN ('failed', 'cancelled')",
|
||||||
@@ -1716,6 +1738,32 @@ async fn launch_direct_session(
|
|||||||
/// the work still applies when one agent does all of it. Erring toward the
|
/// the work still applies when one agent does all of it. Erring toward the
|
||||||
/// union is safe here in a way it would not be on the container tier, where
|
/// union is safe here in a way it would not be on the container tier, where
|
||||||
/// per-role precision is available and used.
|
/// per-role precision is available and used.
|
||||||
|
/// Record the prompt a phase is about to run with.
|
||||||
|
///
|
||||||
|
/// Called from inside each tier rather than at the dispatch point, because the
|
||||||
|
/// tiers do not all send the same text: the three solo tiers send the
|
||||||
|
/// skills-bearing task, and the container tier sends the bare task and appends
|
||||||
|
/// skills per turn in `topology_exec`. Recording at the fork wrote a `solo`
|
||||||
|
/// prompt for container missions too — a prompt that was composed and never
|
||||||
|
/// sent. A provenance record of something that did not happen is worse than no
|
||||||
|
/// record: it is the wrong answer, delivered confidently.
|
||||||
|
async fn record_phase_prompt(
|
||||||
|
pool: &PgPool,
|
||||||
|
mission_id: Uuid,
|
||||||
|
phase_id: Uuid,
|
||||||
|
tier: &str,
|
||||||
|
text: &str,
|
||||||
|
) {
|
||||||
|
let mut ev = crate::mission_events::MissionEvent::new(
|
||||||
|
mission_id,
|
||||||
|
crate::mission_events::PROMPT_COMPOSED,
|
||||||
|
);
|
||||||
|
ev.phase_id = Some(phase_id);
|
||||||
|
ev.target = Some(tier.to_string());
|
||||||
|
ev.detail = serde_json::json!({ "text": text, "tier": tier });
|
||||||
|
crate::mission_events::record(pool, ev).await;
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn phase_skills_text(pool: &PgPool, mission_id: Uuid) -> Option<String> {
|
pub async fn phase_skills_text(pool: &PgPool, mission_id: Uuid) -> Option<String> {
|
||||||
let crew = sqlx::query(
|
let crew = sqlx::query(
|
||||||
"SELECT DISTINCT a.id
|
"SELECT DISTINCT a.id
|
||||||
@@ -1761,11 +1809,10 @@ pub async fn phase_skills_text(pool: &PgPool, mission_id: Uuid) -> Option<String
|
|||||||
));
|
));
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
out.push_str("\n## ");
|
out.push_str(&crate::topology_exec::render_pinned_skill(
|
||||||
out.push_str(&b.skill.name);
|
&b.skill.name,
|
||||||
out.push('\n');
|
&b.skill.body,
|
||||||
out.push_str(&b.skill.body);
|
));
|
||||||
out.push('\n');
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if seen.is_empty() {
|
if seen.is_empty() {
|
||||||
|
|||||||
@@ -1223,6 +1223,42 @@ pub async fn retry_phase(
|
|||||||
/// One row per pass. The `reason` is the operator-facing explanation of why a
|
/// One row per pass. The `reason` is the operator-facing explanation of why a
|
||||||
/// phase iterated (or stopped), and is the same text fed back to the agents as
|
/// phase iterated (or stopped), and is the same text fed back to the agents as
|
||||||
/// guidance for the following pass.
|
/// guidance for the following pass.
|
||||||
|
/// Skill-Use scores for a mission: did the skills we delivered change what the
|
||||||
|
/// agent did?
|
||||||
|
///
|
||||||
|
/// Reads only what was recorded — the prompts the agent received and the
|
||||||
|
/// narratives it returned. An empty result means the evidence is gone (events
|
||||||
|
/// are reaped after 7 days unless `retain_events_until` is set), NOT that no
|
||||||
|
/// skill was followed, and the caller has to present it that way.
|
||||||
|
pub async fn skill_use(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Authed(user): Authed,
|
||||||
|
Path(id): Path<Uuid>,
|
||||||
|
) -> Result<Json<Value>, ApiError> {
|
||||||
|
let _ = cm_db::repo::missions::get(&state.pool, id, user.workspace_id.as_uuid())
|
||||||
|
.await?
|
||||||
|
.ok_or(ApiError::NotFound)?;
|
||||||
|
let scores = crate::skill_use::score_mission(&state.pool, id)
|
||||||
|
.await
|
||||||
|
.map_err(|e| {
|
||||||
|
eprintln!("skill_use: scoring mission {id} failed: {e}");
|
||||||
|
ApiError::Internal
|
||||||
|
})?;
|
||||||
|
Ok(Json(serde_json::json!({
|
||||||
|
"mission_id": id,
|
||||||
|
"skills": scores,
|
||||||
|
// Said in the payload rather than left for the reader to infer: an
|
||||||
|
// empty list has two very different causes and they must not look the
|
||||||
|
// same to whoever consumes this.
|
||||||
|
"evidence": if scores.is_empty() {
|
||||||
|
"no delivered skills found in the recorded prompts — either none \
|
||||||
|
were delivered, or the events have been reaped"
|
||||||
|
} else {
|
||||||
|
"scored from recorded prompt.composed and reasoning events"
|
||||||
|
},
|
||||||
|
})))
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn list_phase_evaluations(
|
pub async fn list_phase_evaluations(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
Authed(user): Authed,
|
Authed(user): Authed,
|
||||||
|
|||||||
@@ -0,0 +1,485 @@
|
|||||||
|
//! Did a skill we delivered actually change what the agent did?
|
||||||
|
//!
|
||||||
|
//! Scored on the three axes from `Skill-Use` (arXiv, 2026-08-05): **Trigger**
|
||||||
|
//! (did the agent reach for the skill), **Compliance** (did it follow the
|
||||||
|
//! procedure), **Boundary** (did it avoid what the skill forbids).
|
||||||
|
//!
|
||||||
|
//! ## One axis does not survive the translation, and saying so is the finding
|
||||||
|
//!
|
||||||
|
//! The paper measures agents under *progressive disclosure*: the agent sees a
|
||||||
|
//! name and a description, and must decide to retrieve the body. The retrieval
|
||||||
|
//! is the Trigger event, and it is observable because it is a tool call.
|
||||||
|
//!
|
||||||
|
//! We do not deliver skills that way on the mission path. `pinned_skills_text`
|
||||||
|
//! inlines full bodies into the prompt, because mission claws run on
|
||||||
|
//! `claude_cli`, which cannot surface a tool call at all — there is nothing to
|
||||||
|
//! retrieve *with*. So the agent never "reaches for" a skill; it is simply
|
||||||
|
//! holding one.
|
||||||
|
//!
|
||||||
|
//! Trigger is therefore **not observable on the mission path**, and this module
|
||||||
|
//! reports it as `NotObservable` with the reason attached rather than scoring
|
||||||
|
//! it zero. A zero would read as "the agents ignore their skills" when it
|
||||||
|
//! actually means "the question does not apply to how we deliver them" — the
|
||||||
|
//! precise confusion that made 55 empty skill bindings invisible for months.
|
||||||
|
//!
|
||||||
|
//! Compliance and Boundary are observable, because they are properties of the
|
||||||
|
//! output rather than of the retrieval.
|
||||||
|
|
||||||
|
use serde::Serialize;
|
||||||
|
|
||||||
|
/// The outcome of one axis for one skill.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
|
||||||
|
#[serde(rename_all = "snake_case", tag = "verdict", content = "why")]
|
||||||
|
pub enum Verdict {
|
||||||
|
Pass,
|
||||||
|
Fail(String),
|
||||||
|
/// The skill says nothing this axis can check.
|
||||||
|
NotApplicable,
|
||||||
|
/// The axis cannot be measured here, for a stated structural reason.
|
||||||
|
///
|
||||||
|
/// Distinct from `Fail` on purpose. Collapsing the two is how a
|
||||||
|
/// measurement reports a system defect as an agent defect.
|
||||||
|
NotObservable(String),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Verdict {
|
||||||
|
pub fn label(&self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
Verdict::Pass => "pass",
|
||||||
|
Verdict::Fail(_) => "FAIL",
|
||||||
|
Verdict::NotApplicable => "n/a",
|
||||||
|
Verdict::NotObservable(_) => "not observable",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One skill's score for one phase.
|
||||||
|
#[derive(Debug, Clone, Serialize)]
|
||||||
|
pub struct SkillUse {
|
||||||
|
pub skill: String,
|
||||||
|
/// `builtin` (hand-authored) or `promoted_from_brain` (agent-authored).
|
||||||
|
///
|
||||||
|
/// Carried into the report because an agent that authors its own skill can
|
||||||
|
/// raise its own compliance score without changing what it does. A rising
|
||||||
|
/// number on agent-authored skills has to be visible as such rather than
|
||||||
|
/// averaged in with the rest.
|
||||||
|
pub source_kind: String,
|
||||||
|
pub trigger: Verdict,
|
||||||
|
pub compliance: Verdict,
|
||||||
|
pub boundary: Verdict,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The skills a prompt actually delivered.
|
||||||
|
///
|
||||||
|
/// Delegates to the delivery layer's own parser, so the reader cannot drift
|
||||||
|
/// from the writer. This function originally matched `## <name>` itself, and
|
||||||
|
/// skill bodies are markdown full of `##` headings — a live mission duly
|
||||||
|
/// scored "Sizing heuristic" and "The output shape" as skills. Parsed from the
|
||||||
|
/// recorded prompt rather than re-derived from the catalogue, because the
|
||||||
|
/// catalogue changes, and now that agents author their own skills it changes
|
||||||
|
/// by itself.
|
||||||
|
pub fn skills_in_prompt(prompt: &str) -> Vec<String> {
|
||||||
|
crate::topology_exec::skill_names_in(prompt)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Score every skill a phase's prompt delivered, against what the agent produced.
|
||||||
|
pub fn score(prompt: &str, output: &str, source_kinds: &dyn Fn(&str) -> String) -> Vec<SkillUse> {
|
||||||
|
skills_in_prompt(prompt)
|
||||||
|
.into_iter()
|
||||||
|
.map(|skill| {
|
||||||
|
let (compliance, boundary) = check(&skill, output);
|
||||||
|
SkillUse {
|
||||||
|
source_kind: source_kinds(&skill),
|
||||||
|
trigger: Verdict::NotObservable(
|
||||||
|
"skills are inlined into the prompt, not retrieved — there is \
|
||||||
|
no retrieval event to observe on the mission path"
|
||||||
|
.into(),
|
||||||
|
),
|
||||||
|
compliance,
|
||||||
|
boundary,
|
||||||
|
skill,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Per-skill mechanical checks.
|
||||||
|
///
|
||||||
|
/// Only skills whose procedure has a machine-checkable consequence are checked.
|
||||||
|
/// Everything else returns `NotApplicable` rather than a guess: a heuristic
|
||||||
|
/// that scores prose by keyword overlap produces a number that looks like a
|
||||||
|
/// measurement and is not one.
|
||||||
|
fn check(skill: &str, output: &str) -> (Verdict, Verdict) {
|
||||||
|
match skill {
|
||||||
|
"int-xx-marker-protocol" => (marker_compliance(output), marker_boundary(output)),
|
||||||
|
"arxiv-daily" => (Verdict::NotApplicable, arxiv_boundary(output)),
|
||||||
|
_ => (Verdict::NotApplicable, Verdict::NotApplicable),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The markers must be ones `task_card_parser` actually parses.
|
||||||
|
///
|
||||||
|
/// Checked by running the real parser rather than a copy of its rules — a
|
||||||
|
/// second implementation of the contract would drift from the first, and then
|
||||||
|
/// the measurement would pass while the mission loop still stalled.
|
||||||
|
fn marker_compliance(output: &str) -> Verdict {
|
||||||
|
if crate::task_card_parser::parse(output).is_empty() {
|
||||||
|
// Only a failure if the output looks like it TRIED. A turn with no
|
||||||
|
// marker-shaped line was probably not a coding turn at all.
|
||||||
|
if output.lines().any(|l| looks_like_marker_attempt(l)) {
|
||||||
|
return Verdict::Fail(
|
||||||
|
"emitted marker-shaped lines that the parser does not accept — \
|
||||||
|
the mission loop will not advance"
|
||||||
|
.into(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return Verdict::NotApplicable;
|
||||||
|
}
|
||||||
|
Verdict::Pass
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Rule 1: exactly one INT id per marker line.
|
||||||
|
///
|
||||||
|
/// Two shapes violate it, and they fail differently:
|
||||||
|
///
|
||||||
|
/// `COMPLETED: INT-05, INT-06` — the parser takes the first and drops the
|
||||||
|
/// rest, so an item is silently never closed.
|
||||||
|
/// `PLAN_COMPLETE: INT-01..02` — the parser ACCEPTS it and yields the id
|
||||||
|
/// `INT-01..02`, which matches no real item.
|
||||||
|
/// A task card appears for something that does
|
||||||
|
/// not exist, and the two items it was meant
|
||||||
|
/// to cover stay open.
|
||||||
|
///
|
||||||
|
/// The second was found by running this measurement against a live mission. It
|
||||||
|
/// is the worse of the two, because a dropped marker leaves a gap and a
|
||||||
|
/// malformed one leaves a plausible-looking row.
|
||||||
|
fn marker_boundary(output: &str) -> Verdict {
|
||||||
|
for line in output.lines() {
|
||||||
|
let t = line.trim();
|
||||||
|
if !looks_like_marker_attempt(t) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let ids = t.matches("INT-").count();
|
||||||
|
if ids > 1 {
|
||||||
|
return Verdict::Fail(format!(
|
||||||
|
"{ids} INT ids on one marker line — only the first parses, so \
|
||||||
|
the rest are silently dropped: {t:?}"
|
||||||
|
));
|
||||||
|
}
|
||||||
|
for m in crate::task_card_parser::parse(t) {
|
||||||
|
if !is_single_int_id(&m.int_id) {
|
||||||
|
return Verdict::Fail(format!(
|
||||||
|
"marker id {:?} is not a single INT-<number> — it parses, so \
|
||||||
|
a task card is created for an item that does not exist, and \
|
||||||
|
the items it was meant to cover stay open: {t:?}",
|
||||||
|
m.int_id
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Verdict::NotApplicable
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `INT-` followed by digits and nothing else.
|
||||||
|
fn is_single_int_id(id: &str) -> bool {
|
||||||
|
match id.strip_prefix("INT-") {
|
||||||
|
Some(rest) => !rest.is_empty() && rest.chars().all(|c| c.is_ascii_digit()),
|
||||||
|
None => false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn looks_like_marker_attempt(line: &str) -> bool {
|
||||||
|
const KINDS: &[&str] = &[
|
||||||
|
"TASK:", "WORK:", "HANDOFF:", "TEST_PASS:", "TEST_FAIL:", "REVIEW_APPROVE:",
|
||||||
|
"REVIEW_BLOCK:", "COMPLETED:", "PLAN_COMPLETE:",
|
||||||
|
];
|
||||||
|
let t = line.trim().trim_start_matches(['*', '#', '-', '`', ' ']);
|
||||||
|
KINDS.iter().any(|k| t.starts_with(k))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `arxiv-daily` forbids searching arXiv — the harvest already ran.
|
||||||
|
///
|
||||||
|
/// This is the one boundary we have watched an agent cross in production, so it
|
||||||
|
/// is checked against the query endpoint rather than the word "arxiv", which
|
||||||
|
/// appears legitimately all over a research turn.
|
||||||
|
fn arxiv_boundary(output: &str) -> Verdict {
|
||||||
|
const QUERY_MARKERS: &[&str] = &[
|
||||||
|
"export.arxiv.org/api/query",
|
||||||
|
"arxiv.org/api/query",
|
||||||
|
"http://export.arxiv.org",
|
||||||
|
];
|
||||||
|
for m in QUERY_MARKERS {
|
||||||
|
if output.contains(m) {
|
||||||
|
return Verdict::Fail(format!(
|
||||||
|
"queried the arXiv API ({m}) — the harvest already ran, and \
|
||||||
|
shelving papers outside it corrupts the seen-set"
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Verdict::Pass
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Score every skill delivered during a mission, from what was recorded.
|
||||||
|
///
|
||||||
|
/// Reads `prompt.composed` and `reasoning` rows. Both are per-mission and
|
||||||
|
/// ordered, so the prompts say what was delivered and the narratives say what
|
||||||
|
/// came back. Scoring is against the CONCATENATED output for the mission rather
|
||||||
|
/// than turn-by-turn: a procedure can be followed in a later turn than the one
|
||||||
|
/// that carried it, and pairing strictly by turn would score that as a failure.
|
||||||
|
///
|
||||||
|
/// Returns an empty vec for a mission whose events have already been reaped —
|
||||||
|
/// which is why `missions.retain_events_until` exists. An empty result means
|
||||||
|
/// "no evidence", never "no compliance", and the report has to say so.
|
||||||
|
pub async fn score_mission(
|
||||||
|
pool: &sqlx::PgPool,
|
||||||
|
mission_id: uuid::Uuid,
|
||||||
|
) -> Result<Vec<SkillUse>, String> {
|
||||||
|
let rows = crate::mission_events::narrative_for_mission(pool, mission_id)
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("read narrative: {e}"))?;
|
||||||
|
|
||||||
|
let mut prompts = String::new();
|
||||||
|
let mut outputs = String::new();
|
||||||
|
for (kind, _, _, text) in &rows {
|
||||||
|
if kind == crate::mission_events::PROMPT_COMPOSED {
|
||||||
|
prompts.push_str(text);
|
||||||
|
prompts.push('\n');
|
||||||
|
} else {
|
||||||
|
outputs.push_str(text);
|
||||||
|
outputs.push('\n');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// One lookup for every delivered name, so the report can separate
|
||||||
|
// hand-authored skills from the ones agents wrote for themselves.
|
||||||
|
let names = skills_in_prompt(&prompts);
|
||||||
|
let kinds: std::collections::HashMap<String, String> = sqlx::query_as::<_, (String, String)>(
|
||||||
|
"SELECT name, source_kind FROM skills WHERE name = ANY($1)",
|
||||||
|
)
|
||||||
|
.bind(&names)
|
||||||
|
.fetch_all(pool)
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("read skill sources: {e}"))?
|
||||||
|
.into_iter()
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
Ok(score(&prompts, &outputs, &|name| {
|
||||||
|
kinds
|
||||||
|
.get(name)
|
||||||
|
.cloned()
|
||||||
|
// A skill in a prompt with no catalogue row was delivered and then
|
||||||
|
// deleted. Naming that explicitly beats defaulting it to builtin.
|
||||||
|
.unwrap_or_else(|| "unknown (no catalogue row)".to_string())
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn builtin(_: &str) -> String {
|
||||||
|
"builtin".to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A prompt exactly as the delivery layer renders it.
|
||||||
|
fn rendered(skills: &[(&str, &str)]) -> String {
|
||||||
|
let body: String = skills
|
||||||
|
.iter()
|
||||||
|
.map(|(n, b)| crate::topology_exec::render_pinned_skill(n, b))
|
||||||
|
.collect();
|
||||||
|
crate::topology_exec::compose_turn_prompt("Task: do the thing", Some(&body))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The prompt is the record of what was delivered, so parsing it must match
|
||||||
|
/// exactly what the delivery layer writes.
|
||||||
|
#[test]
|
||||||
|
fn the_delivered_skills_are_read_back_out_of_the_prompt() {
|
||||||
|
let prompt = rendered(&[
|
||||||
|
("arxiv-daily", "Do not re-search."),
|
||||||
|
("int-xx-marker-protocol", "Emit markers."),
|
||||||
|
]);
|
||||||
|
assert_eq!(
|
||||||
|
skills_in_prompt(&prompt),
|
||||||
|
vec!["arxiv-daily", "int-xx-marker-protocol"],
|
||||||
|
"the scorer reads what the delivery layer wrote — if these drift, \
|
||||||
|
every score is attributed to the wrong skill"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Skill bodies are markdown and contain their own headings.
|
||||||
|
///
|
||||||
|
/// The first delimiter was `## <name>`, so every section of every body
|
||||||
|
/// counted as a separate skill. Caught on a live mission, which scored
|
||||||
|
/// "Sizing heuristic" and "The output shape" — both of them subheadings
|
||||||
|
/// inside `decompose-int-items` — as skills with no catalogue row.
|
||||||
|
#[test]
|
||||||
|
fn headings_inside_a_skill_body_are_not_skills() {
|
||||||
|
let body = "# Decomposing into INT-XX items\n\n\
|
||||||
|
## Sizing heuristic\nOne unit of work.\n\n\
|
||||||
|
## The output shape\nTASK: INT-NN — title\n";
|
||||||
|
let prompt = rendered(&[("decompose-int-items", body)]);
|
||||||
|
assert_eq!(
|
||||||
|
skills_in_prompt(&prompt),
|
||||||
|
vec!["decompose-int-items"],
|
||||||
|
"a body's own headings must not be counted as skills — every one \
|
||||||
|
would be scored against a catalogue row that does not exist"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A body that quotes the marker text must not fabricate a skill either.
|
||||||
|
#[test]
|
||||||
|
fn a_body_mentioning_the_marker_does_not_create_a_skill() {
|
||||||
|
let body = format!(
|
||||||
|
"Prompts introduce a skill with a line beginning `{}`.\n",
|
||||||
|
crate::topology_exec::SKILL_MARKER.trim()
|
||||||
|
);
|
||||||
|
let prompt = rendered(&[("prompt-anatomy", &body)]);
|
||||||
|
assert_eq!(
|
||||||
|
skills_in_prompt(&prompt),
|
||||||
|
vec!["prompt-anatomy"],
|
||||||
|
"the marker is matched at line start; an inline mention is prose"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_prompt_with_no_skills_yields_no_scores() {
|
||||||
|
assert!(skills_in_prompt("Task: do the thing").is_empty());
|
||||||
|
assert!(score("Task: do the thing", "did it", &builtin).is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn trigger_is_reported_as_unobservable_not_as_a_failure() {
|
||||||
|
let prompt = rendered(&[("arxiv-daily", "x")]);
|
||||||
|
let scored = score(&prompt, "I read the manifest.", &builtin);
|
||||||
|
assert!(
|
||||||
|
matches!(scored[0].trigger, Verdict::NotObservable(_)),
|
||||||
|
"scoring Trigger zero would report a delivery-model property as an \
|
||||||
|
agent failure — the exact confusion this module exists to avoid"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn markers_the_real_parser_accepts_are_compliant() {
|
||||||
|
let prompt = rendered(&[("int-xx-marker-protocol", "Emit markers.")]);
|
||||||
|
let good = "I implemented the parser.\nCOMPLETED: INT-07 — wire the loop";
|
||||||
|
let scored = score(&prompt, good, &builtin);
|
||||||
|
assert_eq!(scored[0].compliance, Verdict::Pass);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The failure the skill exists to prevent: marker-shaped lines that the
|
||||||
|
/// parser rejects, so the mission silently never advances.
|
||||||
|
#[test]
|
||||||
|
fn marker_shaped_lines_the_parser_rejects_are_a_failure() {
|
||||||
|
let prompt = rendered(&[("int-xx-marker-protocol", "Emit markers.")]);
|
||||||
|
// Bold, which the skill explicitly forbids, and the parser will not take.
|
||||||
|
let bad = "**COMPLETED: INT-07**";
|
||||||
|
let scored = score(&prompt, bad, &builtin);
|
||||||
|
assert!(
|
||||||
|
matches!(scored[0].compliance, Verdict::Fail(_)),
|
||||||
|
"a marker the parser rejects must score as a failure — that is the \
|
||||||
|
whole consequence the skill is written to avoid. Got {:?}",
|
||||||
|
scored[0].compliance
|
||||||
|
);
|
||||||
|
|
||||||
|
// A turn that never tried is not a violation.
|
||||||
|
let unrelated = score(&prompt, "I read three files and wrote a summary.", &builtin);
|
||||||
|
assert_eq!(unrelated[0].compliance, Verdict::NotApplicable);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Found on a live mission: `PLAN_COMPLETE: INT-01..02`.
|
||||||
|
///
|
||||||
|
/// TWO defects in one line, and the measurement is what surfaced them.
|
||||||
|
///
|
||||||
|
/// 1. `PLAN_COMPLETE` is documented in `int-xx-marker-protocol` as part of
|
||||||
|
/// the ladder, and `task_card_parser` has never implemented it. An agent
|
||||||
|
/// that follows the skill exactly emits a marker that is silently
|
||||||
|
/// ignored — the skill is teaching a contract the platform does not
|
||||||
|
/// honour, which is not the agent's failure.
|
||||||
|
/// 2. The range form yields an id matching no real item on the kinds that
|
||||||
|
/// ARE parsed.
|
||||||
|
#[test]
|
||||||
|
fn a_documented_marker_the_parser_never_implemented_is_a_compliance_failure() {
|
||||||
|
let prompt = rendered(&[("int-xx-marker-protocol", "Emit markers.")]);
|
||||||
|
assert!(
|
||||||
|
crate::task_card_parser::parse("PLAN_COMPLETE: INT-01").is_empty(),
|
||||||
|
"PLAN_COMPLETE is in the skill's ladder and not in the parser — if \
|
||||||
|
this ever starts parsing, the skill and the code have been \
|
||||||
|
reconciled and this test should be updated to match"
|
||||||
|
);
|
||||||
|
let scored = score(&prompt, "PLAN_COMPLETE: INT-01", &builtin);
|
||||||
|
match &scored[0].compliance {
|
||||||
|
Verdict::Fail(why) => assert!(why.contains("does not accept")),
|
||||||
|
other => panic!("an ignored marker must not read as success; got {other:?}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The range form, on a kind the parser DOES accept.
|
||||||
|
#[test]
|
||||||
|
fn a_range_marker_crosses_the_boundary_even_though_it_parses() {
|
||||||
|
let prompt = rendered(&[("int-xx-marker-protocol", "Emit markers.")]);
|
||||||
|
|
||||||
|
let parsed = crate::task_card_parser::parse("COMPLETED: INT-01..02");
|
||||||
|
assert_eq!(parsed.len(), 1, "the parser accepts it");
|
||||||
|
assert_eq!(parsed[0].int_id, "INT-01..02", "with an id matching no item");
|
||||||
|
|
||||||
|
let scored = score(&prompt, "COMPLETED: INT-01..02", &builtin);
|
||||||
|
match &scored[0].boundary {
|
||||||
|
Verdict::Fail(why) => assert!(
|
||||||
|
why.contains("does not exist"),
|
||||||
|
"the failure must name the consequence, not just the syntax: {why}"
|
||||||
|
),
|
||||||
|
other => panic!("a range marker must be caught; got {other:?}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_well_formed_id_is_not_flagged() {
|
||||||
|
assert!(is_single_int_id("INT-07"));
|
||||||
|
assert!(!is_single_int_id("INT-01..02"));
|
||||||
|
assert!(!is_single_int_id("INT-"));
|
||||||
|
assert!(!is_single_int_id("INT-1a"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn two_int_ids_on_one_marker_line_cross_the_boundary() {
|
||||||
|
let prompt = rendered(&[("int-xx-marker-protocol", "Emit markers.")]);
|
||||||
|
let scored = score(&prompt, "COMPLETED: INT-05, INT-06", &builtin);
|
||||||
|
match &scored[0].boundary {
|
||||||
|
Verdict::Fail(why) => assert!(why.contains("silently dropped")),
|
||||||
|
other => panic!("the second id is silently dropped by the parser; got {other:?}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn querying_arxiv_crosses_the_boundary_but_naming_it_does_not() {
|
||||||
|
let prompt = rendered(&[("arxiv-daily", "x")]);
|
||||||
|
|
||||||
|
let violating = score(
|
||||||
|
&prompt,
|
||||||
|
"curl 'http://export.arxiv.org/api/query?search_query=all:agents'",
|
||||||
|
&builtin,
|
||||||
|
);
|
||||||
|
assert!(matches!(violating[0].boundary, Verdict::Fail(_)));
|
||||||
|
|
||||||
|
// The word appears legitimately in every research turn. Scoring on it
|
||||||
|
// would make the metric fire constantly and mean nothing.
|
||||||
|
let fine = score(
|
||||||
|
&prompt,
|
||||||
|
"I read the arXiv notes in the manifest and summarised three of them.",
|
||||||
|
&builtin,
|
||||||
|
);
|
||||||
|
assert_eq!(fine[0].boundary, Verdict::Pass);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Agent-authored skills must stay visible as such in the report.
|
||||||
|
#[test]
|
||||||
|
fn the_source_of_a_skill_is_carried_into_its_score() {
|
||||||
|
let prompt = rendered(&[("self-made", "x")]);
|
||||||
|
let scored = score(&prompt, "done", &|_| "promoted_from_brain".to_string());
|
||||||
|
assert_eq!(
|
||||||
|
scored[0].source_kind, "promoted_from_brain",
|
||||||
|
"an agent that writes its own skill can raise its own score against \
|
||||||
|
it; that has to be legible in the report rather than averaged in"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -49,6 +49,35 @@ const TURN_TIMEOUT: Duration = Duration::from_secs(3600);
|
|||||||
/// foundation set, and it is stated in the prompt when it fires.
|
/// foundation set, and it is stated in the prompt when it fires.
|
||||||
pub(crate) const MAX_PINNED_SKILL_BYTES: usize = 24_000;
|
pub(crate) const MAX_PINNED_SKILL_BYTES: usize = 24_000;
|
||||||
|
|
||||||
|
/// The line that introduces each skill in a prompt.
|
||||||
|
///
|
||||||
|
/// NOT a markdown heading. The first version used `## <name>`, and skill bodies
|
||||||
|
/// are markdown that contain their own `##` headings — so anything reading the
|
||||||
|
/// prompt back counted every section of every body as a separate skill. A live
|
||||||
|
/// mission scored "Sizing heuristic" and "The output shape" as skills, which is
|
||||||
|
/// what surfaced it.
|
||||||
|
///
|
||||||
|
/// This marker cannot occur inside a body, so the prompt stays parseable by
|
||||||
|
/// whatever reads it later. Skills are written by one function
|
||||||
|
/// ([`render_pinned_skill`]) for the same reason: two renderers would drift and
|
||||||
|
/// the reader would silently match only one.
|
||||||
|
pub const SKILL_MARKER: &str = "--- SKILL: ";
|
||||||
|
|
||||||
|
/// One skill, rendered for a prompt.
|
||||||
|
pub fn render_pinned_skill(name: &str, body: &str) -> String {
|
||||||
|
format!("\n{SKILL_MARKER}{name} ---\n{body}\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The skill names a rendered prompt delivered.
|
||||||
|
pub fn skill_names_in(prompt: &str) -> Vec<String> {
|
||||||
|
prompt
|
||||||
|
.lines()
|
||||||
|
.filter_map(|l| l.trim().strip_prefix(SKILL_MARKER))
|
||||||
|
.map(|rest| rest.trim_end_matches(" ---").trim().to_string())
|
||||||
|
.filter(|n| !n.is_empty())
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
pub struct ZeroClawDriveExecutor {
|
pub struct ZeroClawDriveExecutor {
|
||||||
/// Gateway base URL, e.g. `http://127.0.0.1:42617`.
|
/// Gateway base URL, e.g. `http://127.0.0.1:42617`.
|
||||||
gateway_url: String,
|
gateway_url: String,
|
||||||
@@ -329,11 +358,7 @@ impl ZeroClawDriveExecutor {
|
|||||||
));
|
));
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
out.push_str("\n## ");
|
out.push_str(&render_pinned_skill(&b.skill.name, &b.skill.body));
|
||||||
out.push_str(&b.skill.name);
|
|
||||||
out.push('\n');
|
|
||||||
out.push_str(&b.skill.body);
|
|
||||||
out.push('\n');
|
|
||||||
n += 1;
|
n += 1;
|
||||||
}
|
}
|
||||||
if n == 0 {
|
if n == 0 {
|
||||||
|
|||||||
@@ -0,0 +1,150 @@
|
|||||||
|
# Skill-Use baseline
|
||||||
|
|
||||||
|
*First measurement of whether ClawMates' skills change what agents do.
|
||||||
|
2026-08-19.*
|
||||||
|
|
||||||
|
Scored on the three axes from `Skill-Use` (arXiv, 2026-08-05): **Trigger** (did
|
||||||
|
the agent reach for the skill), **Compliance** (did it follow the procedure),
|
||||||
|
**Boundary** (did it avoid what the skill forbids).
|
||||||
|
|
||||||
|
Read the method before the numbers. A measurement whose limits are not stated
|
||||||
|
is worse than none, because it gets quoted without them.
|
||||||
|
|
||||||
|
## Why there was no baseline before today
|
||||||
|
|
||||||
|
Not because nobody ran it. Because **it could not have returned anything but
|
||||||
|
zero**, for two structural reasons that had nothing to do with agent behaviour:
|
||||||
|
|
||||||
|
1. 55 of 85 role skill bindings resolved to skills that were never authored.
|
||||||
|
2. Even resolved skills had no delivery channel to a mission agent — the
|
||||||
|
catalogue's only route was an MCP server that mission claws cannot reach.
|
||||||
|
|
||||||
|
Both were fixed in the two commits preceding this document. Anyone who had run
|
||||||
|
this measurement in July would have concluded "our agents ignore their skills",
|
||||||
|
which would have been false and expensive.
|
||||||
|
|
||||||
|
## Method, and what it cannot see
|
||||||
|
|
||||||
|
Scored by `cm_api::skill_use` from what the platform records: the
|
||||||
|
`prompt.composed` event (the exact bytes an agent received) and the `reasoning`
|
||||||
|
events (what it said it did). No re-derivation from the catalogue — the
|
||||||
|
catalogue changes, and now that agents author their own skills it changes by
|
||||||
|
itself.
|
||||||
|
|
||||||
|
### Trigger is not observable here, and that is a finding
|
||||||
|
|
||||||
|
The paper measures agents under **progressive disclosure**: the agent sees a
|
||||||
|
name and description and must decide to retrieve the body. That retrieval is a
|
||||||
|
tool call, which makes Trigger observable.
|
||||||
|
|
||||||
|
We do not deliver skills that way. `pinned_skills_text` inlines full bodies into
|
||||||
|
the prompt, because mission claws run on `claude_cli`, which cannot surface a
|
||||||
|
tool call — there is nothing to retrieve *with*. The agent never reaches for a
|
||||||
|
skill; it is simply holding one.
|
||||||
|
|
||||||
|
So Trigger is reported as `not_observable` with the reason attached, **never as
|
||||||
|
zero**. Scoring it zero would report a delivery-model property as an agent
|
||||||
|
failure — the same confusion that kept 55 empty bindings invisible.
|
||||||
|
|
||||||
|
### Compliance and Boundary are checked mechanically, or not at all
|
||||||
|
|
||||||
|
Only skills whose procedure has a machine-checkable consequence are scored.
|
||||||
|
Everything else returns `not_applicable` rather than a guess: a heuristic that
|
||||||
|
scores prose by keyword overlap produces a number that looks like a measurement
|
||||||
|
and is not one.
|
||||||
|
|
||||||
|
Compliance for `int-xx-marker-protocol` is checked by running the **real**
|
||||||
|
`task_card_parser`, not a copy of its rules — a second implementation would
|
||||||
|
drift, and then the score would pass while the mission loop still stalled.
|
||||||
|
|
||||||
|
## The runs
|
||||||
|
|
||||||
|
Two missions on the container/ZeroClaw tier, local stack, `research_only`.
|
||||||
|
|
||||||
|
| | run 1 | run 2 |
|
||||||
|
|---|---|---|
|
||||||
|
| distinct skills delivered | 3 | **9** |
|
||||||
|
| total deliveries (per role prompt) | 3 | 14 |
|
||||||
|
| phantom "skills" scored | **2** | 0 |
|
||||||
|
|
||||||
|
Run 1's phantom entries are the finding of the run, described below.
|
||||||
|
|
||||||
|
Run 2, per skill (all `source_kind=builtin`; no agent-authored skill has been
|
||||||
|
delivered yet):
|
||||||
|
|
||||||
|
| skill | deliveries | compliance | boundary |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `int-xx-marker-protocol` | 1 | **pass** | n/a |
|
||||||
|
| `small-focused-commits` | 4 | n/a | n/a |
|
||||||
|
| `cargo-test-driven-development` | 2 | n/a | n/a |
|
||||||
|
| `workspace-repo-commit-protocol` | 2 | n/a | n/a |
|
||||||
|
| `decompose-int-items` | 1 | n/a | n/a |
|
||||||
|
| `write-rust-current-edition` | 1 | n/a | n/a |
|
||||||
|
| `code-review-checklist` | 1 | n/a | n/a |
|
||||||
|
| `criterion-benchmarking` | 1 | n/a | n/a |
|
||||||
|
| `tdd-red-green-refactor` | 1 | n/a | n/a |
|
||||||
|
|
||||||
|
**n = 2 runs. No spread is reported because two runs cannot establish one.**
|
||||||
|
This is a baseline in the sense of "the first honest number", not in the sense
|
||||||
|
of `metrics-baseline-comparison.md`, which requires enough runs to see the noise
|
||||||
|
floor before any change is judged against it. Do not compare a future number to
|
||||||
|
this one without first establishing that floor.
|
||||||
|
|
||||||
|
## What the measurement found
|
||||||
|
|
||||||
|
Three defects, none of which any test or log would have surfaced.
|
||||||
|
|
||||||
|
### 1. The prompt format made its own record unparseable
|
||||||
|
|
||||||
|
Skills were introduced with `## <name>`, and skill bodies are markdown full of
|
||||||
|
`##` headings. Run 1 duly scored **"Sizing heuristic"** and **"The output
|
||||||
|
shape"** — both subheadings inside `decompose-int-items` — as skills with no
|
||||||
|
catalogue row.
|
||||||
|
|
||||||
|
Fixed with an unambiguous `--- SKILL: <name> ---` marker, and both writers now
|
||||||
|
share one renderer so the reader cannot drift from the writer.
|
||||||
|
|
||||||
|
### 2. A prompt was recorded that was never sent
|
||||||
|
|
||||||
|
The phase prompt was recorded at the dispatch fork, before the tier was chosen.
|
||||||
|
The container tier does not send that text — it sends the bare task and appends
|
||||||
|
skills per turn. So every container mission logged a `solo` prompt that reached
|
||||||
|
no agent.
|
||||||
|
|
||||||
|
A provenance record of something that did not happen is worse than no record: it
|
||||||
|
is the wrong answer, delivered confidently. Recording now happens inside each
|
||||||
|
tier, and a test asserts every launcher records the prompt it actually sends.
|
||||||
|
|
||||||
|
### 3. The skill documents a marker the platform never implemented
|
||||||
|
|
||||||
|
`int-xx-marker-protocol` lists `PLAN_COMPLETE: INT-NN` in its ladder.
|
||||||
|
`task_card_parser` has **no such kind** and never has. An agent following the
|
||||||
|
skill exactly emits a marker that is silently ignored.
|
||||||
|
|
||||||
|
Observed live: run 2's planner emitted `PLAN_COMPLETE: INT-01..02`, which is
|
||||||
|
also the range form — on the kinds that *are* parsed, that yields the id
|
||||||
|
`INT-01..02`, a task card for an item that does not exist while the two real
|
||||||
|
items stay open.
|
||||||
|
|
||||||
|
**This is a skill/implementation mismatch, not an agent failure**, and it is
|
||||||
|
precisely what this measurement exists to find: the agent did what it was told,
|
||||||
|
and what it was told was wrong. Both shapes are now scored as failures; the
|
||||||
|
underlying reconciliation — implement `PLAN_COMPLETE` or remove it from the
|
||||||
|
skill — is deliberately left as a decision rather than guessed at here.
|
||||||
|
|
||||||
|
## Honest limits
|
||||||
|
|
||||||
|
- **Two runs, one tier, one workflow.** Nothing here generalises to the microVM
|
||||||
|
or session tiers yet, and this document should not be read as if it does.
|
||||||
|
- **7 of 9 skills scored `not_applicable`** on both observable axes. That is not
|
||||||
|
a pass. It means we cannot currently tell whether those skills changed
|
||||||
|
anything, and the next unit of work is mechanical checks for more of them —
|
||||||
|
`workspace-repo-commit-protocol` and `small-focused-commits` both have
|
||||||
|
checkable consequences in git history.
|
||||||
|
- **No agent-authored skill has been measured.** Self-authoring shipped in the
|
||||||
|
same pass; `source_kind` is carried through the scorer specifically so a
|
||||||
|
rising score on agent-authored skills is visible rather than averaged in.
|
||||||
|
- **Evidence expires.** Mission events are reaped after 7 days unless
|
||||||
|
`retain_events_until` is set. Both runs here are held for 90 days. An empty
|
||||||
|
score means "no evidence", never "no compliance", and the API says so in its
|
||||||
|
payload rather than leaving the caller to infer it.
|
||||||
Reference in New Issue
Block a user