Files
clawmates/crates/cm-api/src/skill_use.rs
T
Omar SobhandClaude Opus 5 758760cedb
deploy / test (push) Successful in 5m19s
deploy / build (push) Successful in 5m44s
feat(skill-use): a compliance check for web-search-triage, from what the tap recorded
The scorer could see that agents OPENED web-search-triage (trigger=pass on both
files-arm runs) and nothing about whether they followed it — compliance was
not_applicable because no mechanical check existed. The evidence was in the
recorded arguments the whole time. On the runs that read the skill, the parent
decomposed the sweep into per-source fetches and sent each to a subagent; on
01a09b42 two of those spawn prompts read "Return the URL, date if visible, and
the key content". The task never asked for a date. The skill's "undated is a
finding" did. On the runs that did not read it: inline curls, no subagents, no
date.

Two of the skill's rules leave a mark in arguments, and the check scores
exactly those two. The ranking rule: every URL a fetch was sent to is classified
against a short allow-list of primary hosts (rank 0) and a short skip-list of
aggregators (rank 3+); fetching an aggregator is the visible violation, fetching
primary sources the visible compliance, and anything unrecognised is unranked
and decides nothing. The date rule: reported as extra evidence on a pass, never
required for one, because a curl to an abstract page has no prompt to ask in.

`Agent` is a fetching tool here on purpose. The URLs on the files-arm runs live
in the spawn PROMPT; a check that only read curl lines would have scored those
runs as fetching nothing.

`Verdict::PassWith(String)` carries the evidence and serialises under the same
"pass" tag, so no reader grows a fourth branch and the one that looks finds
the date fingerprint in `why`.

One-sided like every check in this module: no tools is not observable, no fetch
is not applicable, an unrankable fetch is not a violation.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01WZb5A2kfVfjpdwSochkuHz
2026-09-13 17:01:36 -05:00

2091 lines
86 KiB
Rust

//! 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, 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.
//!
//! ### The reason changed, and only half of it went away
//!
//! Until 2026-08-21 the stated reason was that `claude_cli` "cannot surface a
//! tool call at all — there is nothing to retrieve *with*". That half is now
//! false: [`crate::container_tool_hooks`] installs `PostToolUse`, mission tool
//! calls land in `mission_events`, and a retrieval would be as visible as any
//! other call.
//!
//! The other half still holds, and it is the one that decides the verdict: we
//! still **inline**. Trigger is now *instrumentable* and still not
//! *observable*, and the blocker has moved from the transport to the delivery
//! model. Making it real is one change — serve skills through the door
//! (`docs/TOOL-CALL-ARCHITECTURE.md` §3) so retrieval becomes a tool call —
//! and nothing else in this module has to change to score it.
//!
//! ## Compliance and Boundary are scored from ACTIONS where actions exist
//!
//! They were scored from the concatenated narrative, which is the agent's own
//! account of its turn — written by the thing being measured, and silent about
//! anything it did not think worth mentioning. [`Evidence`] carries the
//! recorded tool calls alongside that text, and every check prefers them:
//! `Bash` arguments say what ran, `Write` paths say where it landed.
//!
//! Every tool-backed check is **one-sided**. It reports a violation it can see
//! and never infers compliance from silence — the recorded stream is capped
//! per phase ([`crate::mission_events::PER_PHASE_CAP`]), so an absent call is
//! not proof of an absent action.
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,
/// A pass that can say what it saw. Same tag as [`Verdict::Pass`] on the
/// wire — `{"verdict":"pass","why":…}` — so every reader that keys on the
/// tag is unaffected and the evidence is there for the one that looks.
///
/// Exists because a bare pass on `web-search-triage` would have hidden
/// the only finding worth having: the agent's spawn prompts asked for the
/// page's date, which the task never did and the skill does.
#[serde(rename = "pass")]
PassWith(String),
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 | Verdict::PassWith(_) => "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)
}
/// What a mission left behind, for the checks to read.
///
/// Two sources, deliberately kept apart. `text` is what the agent *said* it
/// did; `tools` is what it did. Where they disagree the tools win, and a check
/// that can only consult `text` says so in its verdict rather than presenting a
/// narrative reading as an observation.
pub struct Evidence<'a> {
/// Concatenated `reasoning` events for the mission.
pub text: &'a str,
/// Every `tool.call` recorded for the mission, in order.
pub tools: &'a [crate::mission_events::ToolEvidence],
}
/// Tools that modify a file. `target` on the recorded event is the tool name.
const WRITE_TOOLS: [&str; 4] = ["Write", "Edit", "MultiEdit", "NotebookEdit"];
impl<'a> Evidence<'a> {
pub fn new(text: &'a str, tools: &'a [crate::mission_events::ToolEvidence]) -> Self {
Evidence { text, tools }
}
/// Narrative only — a mission whose events predate the tool tap.
pub fn from_text(text: &'a str) -> Self {
Evidence { text, tools: &[] }
}
/// Every shell command the agent ran, in order.
fn commands(&self) -> impl Iterator<Item = &str> {
self.tools.iter().filter_map(|t| t.command())
}
/// Test runs, in order, each with whether it went red or green.
///
/// `None` where the outcome was not recorded — a mission whose events
/// predate the response capture, which must not read as either.
fn test_runs(&self) -> Vec<Option<bool>> {
self.tools
.iter()
.filter(|t| t.command().is_some_and(is_test_command))
.map(|t| test_passed(&t.response))
.collect()
}
/// Every file-modifying call that named a path, in order.
fn writes(&self) -> impl Iterator<Item = &crate::mission_events::ToolEvidence> {
self.tools
.iter()
.filter(|t| WRITE_TOOLS.contains(&t.tool.as_str()) && t.path.is_some())
}
}
/// Skills the agent RETRIEVED, in the order it reached for them.
///
/// This is Trigger, and it is observable the moment a skill arrives by
/// retrieval instead of by inlining. Claude Code reads an MCP resource through
/// `ReadMcpResourceTool`, and the recorded arguments carry the URI:
///
/// ```text
/// ReadMcpResourceTool {"uri":"skill:global/workspace-repo-commit-protocol",
/// "server":"clawmates_skills"}
/// ```
///
/// The URI is parsed by `mcp_skills::parse_uri` — the same function that
/// WROTE it — rather than by a second matcher here, for the reason every other
/// reader in this module delegates: two implementations of one format drift,
/// and the drift shows up as a skill silently scoring nothing.
pub fn retrieved_skills(ev: &Evidence<'_>) -> Vec<String> {
let mut out = Vec::new();
for t in ev.tools {
let name = match t.tool.as_str() {
"ReadMcpResourceTool" => t
.input
.get("uri")
.and_then(|v| v.as_str())
.and_then(crate::mcp_skills::parse_uri)
.map(|(_, name)| name),
// The `files` arm: the pointer is a path and the retrieval is a
// plain `Read`. Matched through `skill_from_file_path`, the reader
// half of the function that wrote the path, for the same reason
// the uri goes through `parse_uri`. A `Read` anywhere else is an
// ordinary file read and is not a retrieval of anything.
"Read" => t
.input
.get("file_path")
.and_then(|v| v.as_str())
.and_then(crate::skill_delivery::skill_from_file_path),
_ => None,
};
if let Some(name) = name {
if !out.contains(&name) {
out.push(name);
}
}
}
out
}
/// Did the agent reach for this skill?
///
/// The answer depends on whether it was ever given the chance, which is what
/// the delivery arm decides — so this takes the arm rather than assuming one.
/// Getting that wrong is not a rounding error: under `Index` the old text
/// would have said "this skill was inlined into the prompt" about a skill that
/// was not, and scored a real miss as a structural blind spot.
fn trigger_verdict(
mode: crate::skill_delivery::Mode,
retrieved: bool,
compliance: &Verdict,
boundary: &Verdict,
) -> Verdict {
if retrieved {
// The agent reached for it. That is the paper's Trigger, and it is a
// recorded tool call like any other.
return Verdict::Pass;
}
match mode {
// Handed over, so there was no reaching-for to observe. Not a failure
// and not a pass — the axis simply does not exist in this arm.
crate::skill_delivery::Mode::Inline => Verdict::NotObservable(
"this skill was inlined into the prompt, not retrieved — the agent \
was handed it, so there is no reaching-for to observe. Serve it \
through the door instead and this becomes a tool call"
.into(),
),
// Both retrieval arms: offered by name and `when_to_use`, and never
// opened. Whether that is a miss depends on whether the skill had
// anything to say about this phase at all: a skill with no
// machine-checkable consequence here is one an agent is right to pass
// over, and scoring that as a failure would punish correct triage.
crate::skill_delivery::Mode::Index | crate::skill_delivery::Mode::Files => {
if matches!(compliance, Verdict::NotApplicable)
&& matches!(boundary, Verdict::NotApplicable)
{
Verdict::NotApplicable
} else {
Verdict::Fail(
"offered in the index with its `when_to_use`, and never read — \
the agent had the entry in front of it and did not fetch the \
procedure"
.into(),
)
}
}
}
}
/// Score every skill a phase's prompt delivered, against what the agent produced.
pub fn score(
prompt: &str,
evidence: &Evidence<'_>,
source_kinds: &dyn Fn(&str) -> String,
) -> Vec<SkillUse> {
let retrieved = retrieved_skills(evidence);
// Read off the prompt that was actually sent, not off the mission row: the
// row says what the mission is configured to do now, and this is scoring a
// turn that ran then.
let mode = crate::skill_delivery::mode_in_prompt(prompt);
// Delivered by either route. A skill that was retrieved and never inlined
// is invisible to `skills_in_prompt`, and under progressive disclosure that
// is EVERY skill — so scoring only the prompt would report zero for the
// delivery model this axis exists to measure.
let mut names = skills_in_prompt(prompt);
for r in &retrieved {
if !names.contains(r) {
names.push(r.clone());
}
}
names
.into_iter()
.map(|skill| {
let (compliance, boundary) = check(&skill, evidence);
// The arm belongs to the prompt; `always_inject` belongs to the
// skill. A skill whose BODY is in the prompt was handed over, so
// there is no reaching-for to observe even under `Index` — scoring
// it as a Trigger miss would report a failure against an agent that
// was never asked to fetch anything.
let delivered = match crate::skill_delivery::skill_was_indexed(prompt, &skill) {
Some(false) => crate::skill_delivery::Mode::Inline,
_ => mode,
};
SkillUse {
source_kind: source_kinds(&skill),
trigger: trigger_verdict(
delivered,
retrieved.contains(&skill),
&compliance,
&boundary,
),
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, ev: &Evidence<'_>) -> (Verdict, Verdict) {
match skill {
"int-xx-marker-protocol" => (marker_compliance(ev.text), marker_boundary(ev.text)),
"arxiv-daily" => (Verdict::NotApplicable, arxiv_boundary(ev)),
"workspace-repo-commit-protocol" => (Verdict::NotApplicable, workspace_boundary(ev)),
"small-focused-commits" => (Verdict::NotApplicable, commit_subject_boundary(ev)),
"web-search-triage" => (triage_compliance(ev), Verdict::NotApplicable),
// One check for both: `cargo-test-driven-development` is the Rust
// flavour of the same loop, and its own text says so. Scoring them by
// separate rules would mean two rules for one procedure, which is how
// the microVM and container taps drifted apart.
"tdd-red-green-refactor" | "cargo-test-driven-development" => {
(red_before_green(ev), Verdict::NotApplicable)
}
_ => (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 range form. The parser now REJECTS a
/// malformed id, so this is caught by the
/// compliance check above as a marker the
/// parser does not accept.
///
/// 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:?}"
));
}
}
Verdict::NotApplicable
}
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))
}
/// The mission checkout is `/mission/repo`. Working anywhere else is not
/// delivered.
///
/// Scored as a BOUNDARY rather than compliance: the skill's positive
/// instruction ("cd there at the start of every substantive turn") has no
/// reliable trace in the output, but writing source somewhere that is never
/// collected does, and it is the failure that costs a whole phase.
///
/// This check exists because the skill itself was wrong. It taught
/// `/workspace/repo` — a path the platform does not mount — while the same
/// prompt told the agent `/mission/repo`. An agent that obeyed the skill wrote
/// into a directory nothing collects. Corrected 2026-08-19, and
/// `skills_loader::contradiction_tests` now holds it.
fn workspace_boundary(ev: &Evidence<'_>) -> Verdict {
// The recorded writes settle this directly. Before the tool tap the only
// available evidence was the narrative, which answers a different question:
// an agent that wrote to the wrong root without narrating it scored a
// clean pass.
let stray: Vec<&str> = ev
.writes()
.filter_map(|w| w.path.as_deref())
.filter(|p| out_of_bounds(p))
.collect();
if let Some(first) = stray.first() {
return Verdict::Fail(format!(
"wrote to {first} ({} write(s) outside the checkout) — the mission \
checkout is /mission/repo, so anything written there is never \
collected and the phase delivers nothing",
stray.len()
));
}
// `--force` is the skill's other hard prohibition, and unlike the path rule
// it leaves no trace anywhere else: the push succeeds and the history it
// overwrote is gone.
if let Some(cmd) = ev.commands().find(|c| is_force_push(c)) {
return Verdict::Fail(format!(
"force-pushed ({cmd:?}) — the skill allows it only after an explicit \
`HANDOFF: safe to force-push` from the reviewer, because rewriting \
pushed history is the one thing here that cannot be undone"
));
}
// No violation seen. Whether that is a pass depends on whether there was
// anything to see.
if ev.writes().next().is_some() {
return Verdict::Pass;
}
if ev.text.contains("/workspace/repo") {
return Verdict::Fail(
"the narrative describes working in /workspace/repo — the mission \
checkout is /mission/repo, so anything written there is never \
collected and the phase delivers nothing"
.into(),
);
}
if ev.text.contains("/mission/repo") {
return Verdict::Pass;
}
// Silence is not compliance. This returned `Pass` before, which is how a
// mission with no evidence at all scored the same as one that was checked.
Verdict::NotObservable(
"no file-modifying tool call was recorded and the narrative names no \
checkout — there is nothing here to place either inside or outside \
/mission/repo"
.into(),
)
}
/// Is this write outside the mission checkout?
///
/// Relative paths are skipped: the working directory is not recorded, so
/// `src/a.rs` could be either, and guessing would fail honest work.
///
/// The scratch roots are excluded because the skill itself blesses them —
/// "anything else is scratch and will not be delivered" — and the hooks, the
/// stop gate and claude's own state all live under `/root` by design.
fn out_of_bounds(path: &str) -> bool {
const SCRATCH: [&str; 5] = ["/tmp/", "/var/tmp/", "/root/", "/dev/", "/proc/"];
path.starts_with('/')
&& !path.starts_with("/mission/repo")
&& !SCRATCH.iter().any(|s| path.starts_with(s))
}
/// Matched on whole tokens, outside the commit message.
///
/// Both halves are load-bearing, and each was a live false positive before the
/// negative control caught it:
///
/// - `cmd.contains("-f ")` fires on `--follow-tags`.
/// - Tokenising the whole command fires on
/// `git commit -m "document the -f flag" && git push`, where `-f` is prose.
///
/// A boundary check that fails honest work gets switched off, which costs more
/// than the check was worth.
fn is_force_push(cmd: &str) -> bool {
const FORCE: [&str; 3] = ["--force", "-f", "--force-with-lease"];
let stripped = match dash_m_value(cmd) {
Some(msg) => cmd.replace(&msg, " "),
None => cmd.to_string(),
};
let tokens = || stripped.split_whitespace();
let pushes = stripped.contains("git") && tokens().any(|t| t == "push");
pushes && tokens().any(|t| FORCE.contains(&t))
}
/// The commit subjects the skill names as never-merge.
///
/// Matched exactly, after stripping an `INT-NN` prefix. A substring match would
/// fail `fix the parser panic`, which is a perfectly good subject — the skill's
/// objection is to `fix` as the WHOLE message, not to the word.
fn commit_subject_boundary(ev: &Evidence<'_>) -> Verdict {
const NEVER_MERGE: [&str; 7] = [
"wip",
"fix",
"update",
"updates",
"update stuff",
"stuff",
"wip commit",
];
for subject in commit_subjects(ev) {
let bare = strip_int_prefix(&subject).to_ascii_lowercase();
let bare = bare.trim_matches(|c: char| !c.is_alphanumeric()).trim();
if NEVER_MERGE.contains(&bare) {
return Verdict::Fail(format!(
"committed {subject:?} — the skill lists this exact message as \
never-merge, because a reviewer and a bisect both need the \
subject to stand alone in `git log --oneline`"
));
}
}
Verdict::NotApplicable
}
fn strip_int_prefix(subject: &str) -> &str {
let s = subject.trim().trim_start_matches(['<', '[', '(']);
let Some(rest) = s.strip_prefix("INT-") else {
return subject.trim();
};
let rest = rest.trim_start_matches(|c: char| c.is_ascii_digit());
rest.trim_start_matches(['>', ']', ')', ':', '-', '—', ' '])
.trim()
}
/// Did a test ever run, and did it run first?
///
/// One-sided by construction, because only one of the three answers is
/// unambiguous:
///
/// - **Files written, no test command anywhere** — red-green did not happen.
/// There is no reading of the loop under which it did.
/// - **A test ran before the first write** — that is the RED step, observed.
/// - **A test ran after the first write** — undecidable, and reported as such.
/// In Rust the unit test lives in `#[cfg(test)] mod tests` *inside the file
/// under test, so "wrote the file, then ran the test" is what writing the
/// failing test first looks like from here. Scoring that as a failure would
/// fail the agents who followed the skill most precisely.
/// Did this test run pass?
///
/// Read from the runner's own verdict line, not from an exit code — Claude
/// Code's `tool_response` carries `stdout`/`stderr`/`interrupted` and no
/// status. That is why `bounded_response` keeps the END of the output: every
/// one of these markers is printed last.
fn test_passed(response: &serde_json::Value) -> Option<bool> {
let text = format!(
"{}\n{}",
response.get("stdout").and_then(|v| v.as_str()).unwrap_or(""),
response.get("stderr").and_then(|v| v.as_str()).unwrap_or("")
);
if text.trim().is_empty() {
return None;
}
const FAILED: [&str; 6] = [
"test result: FAILED",
"FAILED (",
"error: test failed",
"Tests: ",
"failures:",
"error[E",
];
const PASSED: [&str; 4] = ["test result: ok", "ok. ", "passed", "PASS"];
// Failure first: a run that reports both a passing binary and a failing one
// is a failing run, and the summary line of the failure is what matters.
if FAILED.iter().any(|m| text.contains(m)) {
return Some(false);
}
if PASSED.iter().any(|m| text.contains(m)) {
return Some(true);
}
None
}
fn red_before_green(ev: &Evidence<'_>) -> Verdict {
if ev.tools.is_empty() {
return Verdict::NotObservable(
"no tool calls were recorded for this mission — the order of test \
runs and writes is what this check reads, and neither is in the \
narrative"
.into(),
);
}
let first_write = ev.tools.iter().position(|t| {
WRITE_TOOLS.contains(&t.tool.as_str()) && t.path.as_deref().is_some_and(is_source)
});
let first_test = ev
.tools
.iter()
.position(|t| t.command().is_some_and(is_test_command));
match (first_write, first_test) {
// Nothing was written, so there was no implementation to test-drive.
(None, _) => Verdict::NotApplicable,
(Some(_), None) => Verdict::Fail(format!(
"wrote {} source file(s) and never ran a test — the loop is red, \
green, refactor, and a test that never ran cannot have been red",
ev.writes()
.filter(|w| w.path.as_deref().is_some_and(is_source))
.count()
)),
(Some(w), Some(t)) if t < w => Verdict::Pass,
_ => {
// Ordering is exhausted here, and so is the repository: the skill
// says to "commit the RED-to-GREEN pair as one commit", so the
// failing test and its fix land together by instruction. The only
// remaining witness is what the runs THEMSELVES reported.
let runs = ev.test_runs();
let went_red = runs.iter().position(|r| *r == Some(false));
let went_green = runs.iter().rposition(|r| *r == Some(true));
match (went_red, went_green) {
(Some(red), Some(green)) if red < green => Verdict::Pass,
(Some(_), None) => Verdict::Fail(
"every recorded test run failed — the loop ends on green, \
and this phase never got there"
.into(),
),
_ if runs.iter().all(Option::is_none) => Verdict::NotObservable(
"a test ran after the first write and no run recorded its \
output — in Rust the unit test lives in the file under \
test, so ordering cannot separate red-first from \
tests-added-after, and without the run's own verdict there \
is nothing else to read"
.into(),
),
_ => Verdict::NotObservable(
"every recorded test run passed — a test that never failed \
is a test that was never red, but it is equally what a \
correct implementation written first looks like, and these \
runs cannot tell them apart"
.into(),
),
}
}
}
}
/// Is this file behaviour-adding code, in the languages the skill names?
///
/// The check is scoped to source because the first live run scored `fail`
/// against a **research** phase: its agents wrote fifteen markdown notes and
/// never ran a test, which is not a violation of anything — there was no code
/// to test-drive. Reporting that as an agent failure would be a system defect
/// wearing an agent's name, and the real finding it obscures is that a research
/// role is pinned TDD skills at all.
///
/// Extensions the skill itself names — "Rust, TypeScript, Python, anywhere
/// tests can run cheap" — plus their immediate neighbours. Prose, config and
/// data are excluded. Shell is excluded deliberately: a helper script written
/// during a research turn is not the behaviour-adding code this loop is about,
/// and the false failure costs more than the missed one.
fn is_source(path: &str) -> bool {
const SOURCE: [&str; 10] = [
".rs", ".ts", ".tsx", ".py", ".js", ".jsx", ".go", ".java", ".rb", ".kt",
];
let p = path.to_ascii_lowercase();
SOURCE.iter().any(|e| p.ends_with(e))
}
/// Commands that run a test suite, across the languages the skills name.
fn is_test_command(cmd: &str) -> bool {
const RUNNERS: [&str; 9] = [
"cargo test",
"cargo nextest",
"cargo llvm-cov",
"npm test",
"yarn test",
"pnpm test",
"vitest",
"pytest",
"go test",
];
// `jest` on its own is too short to match safely — it appears inside
// package names and paths — so it is matched as a command word.
RUNNERS.iter().any(|r| cmd.contains(r))
|| cmd.split_whitespace().any(|w| w == "jest" || w == "npx")
&& cmd.contains("jest")
}
/// The subject line of every commit the agent made.
///
/// Read out of the `-m` argument. A commit written with `-F` or a heredoc
/// yields nothing, and the caller must treat an empty result as "no evidence"
/// rather than "no commits" — which is why every commit check here returns
/// `NotApplicable` on an empty list instead of a verdict.
fn commit_subjects(ev: &Evidence<'_>) -> Vec<String> {
ev.commands()
.filter(|c| c.contains("git commit") || c.contains("git ci"))
.filter_map(dash_m_value)
.filter_map(|m| subject_line(&m))
.collect()
}
/// The subject out of a `-m` value.
///
/// Normally the first line. But Claude Code writes a multi-line message as
///
/// ```text
/// git commit -m "$(cat <<'EOF'
/// INT-01 Add slugify function
///
/// …body…
/// EOF
/// )"
/// ```
///
/// and the first line of that value is `$(cat <<'EOF'` — the heredoc *opener*,
/// not the subject. Observed on the first live coding run; it scored no
/// violation only because `$(cat <<'EOF'` happens not to be one of the
/// never-merge messages, which is luck, not a check.
///
/// So: if the first line opens a heredoc or a command substitution, the subject
/// is the next non-empty line.
fn subject_line(msg: &str) -> Option<String> {
let mut lines = msg.lines().map(str::trim).filter(|l| !l.is_empty());
let first = lines.next()?;
if first.starts_with("$(") || first.contains("<<") {
return lines.next().map(str::to_string);
}
Some(first.to_string())
}
/// The value of the `-m` flag in a shell command.
///
/// Hand-scanned rather than split on whitespace: the value is the one argument
/// guaranteed to contain spaces, and the commit template in
/// `workspace-repo-commit-protocol` spans several lines.
fn dash_m_value(cmd: &str) -> Option<String> {
let b = cmd.as_bytes();
for i in 0..b.len() {
// Anchor on the `m`, not on `-m`: agents write `git commit -am …` and
// anchoring on the dash misses the whole cluster form.
if b[i] != b'm' {
continue;
}
// `m` must END the cluster, so `--message` and `-mtime` are not it.
if !matches!(
b.get(i + 1),
None | Some(b' ') | Some(b'\t') | Some(b'"') | Some(b'\'')
) {
continue;
}
// Walk back over the cluster's other short flags to its single dash.
let mut j = i;
while j > 0 && b[j - 1].is_ascii_alphabetic() {
j -= 1;
}
if j == 0 || b[j - 1] != b'-' {
continue;
}
let dash = j - 1;
// One dash, starting a word — not `--m` and not the tail of a path.
if dash > 0 && !b[dash - 1].is_ascii_whitespace() {
continue;
}
let mut k = i + 1;
while k < b.len() && b[k].is_ascii_whitespace() {
k += 1;
}
if k >= b.len() {
return None;
}
let rest = &cmd[k..];
let quote = rest.chars().next().filter(|c| *c == '"' || *c == '\'');
return match quote {
Some(q) => rest[1..].split(q).next().map(str::to_string),
None => rest.split_whitespace().next().map(str::to_string),
};
}
None
}
/// The tools whose arguments carry a URL the agent reached for.
///
/// `Agent` is here on purpose. On both `files`-arm runs the parent decomposed
/// the sweep into per-source fetches and sent each to a subagent — the URLs
/// live in the spawn PROMPT, and a check that only read `curl` lines would
/// have scored those runs as fetching nothing.
const FETCH_TOOLS: &[&str] = &["Bash", "Agent", "WebFetch", "web_fetch"];
/// Every `http(s)` URL in the arguments of a fetching tool, in call order.
fn fetched_urls(ev: &Evidence<'_>) -> Vec<String> {
let mut out = Vec::new();
for t in ev.tools {
if !FETCH_TOOLS.contains(&t.tool.as_str()) {
continue;
}
// A `Bash` that never fetches is most of what agents run.
if t.tool == "Bash"
&& !t
.command()
.is_some_and(|c| c.contains("curl") || c.contains("wget"))
{
continue;
}
let text = t.input.to_string();
let mut rest = text.as_str();
while let Some(i) = rest.find("http") {
let cand = &rest[i..];
let end = cand
.find(|c: char| {
c.is_whitespace() || matches!(c, '"' | '\'' | '<' | '>' | ')' | ']' | '\\')
})
.unwrap_or(cand.len());
let url = cand[..end].trim_end_matches(|c| matches!(c, '.' | ',' | ';' | ':'));
if url.starts_with("http://") || url.starts_with("https://") {
out.push(url.to_string());
}
rest = &cand[end.max(4)..];
}
}
out
}
/// Rank 0 in `web-search-triage`'s ladder: the paper, the spec, the release.
///
/// A conservative allow-list. Anything not on it is UNRANKED, not rank 3 — a
/// vendor's docs, an author's own blog and a lab page are all rank 0 or 1 and
/// none of them can be recognised by hostname.
fn is_primary_source(url: &str) -> bool {
const HOSTS: &[&str] = &[
"arxiv.org/abs/",
"arxiv.org/pdf/",
"doi.org/",
"aclanthology.org/",
"openreview.net/",
"proceedings.neurips.cc/",
"proceedings.mlr.press/",
"dl.acm.org/doi/",
"ieeexplore.ieee.org/",
"github.com/",
"nature.com/articles/",
"science.org/doi/",
];
HOSTS.iter().any(|h| url.contains(h))
}
/// Rank 3 and below: a restatement of a restatement. The skill's own words are
/// "the same item and should be recorded once, if at all", and its skip signals
/// — "a numbered list of tools", no date — describe these hosts.
///
/// Also conservative. Substack, X and personal blogs are NOT here: an author's
/// own post is rank 1 and the skill says to read it.
fn is_aggregator(url: &str) -> bool {
const HOSTS: &[&str] = &[
"medium.com/",
"towardsdatascience.com/",
"reddit.com/",
"news.ycombinator.com/",
"quora.com/",
"dev.to/",
"linkedin.com/",
"wikipedia.org/",
];
HOSTS.iter().any(|h| url.contains(h))
}
/// `web-search-triage`: read the primary source, and treat undated as a finding.
///
/// Two of the skill's rules leave a mark in the recorded ARGUMENTS, and this
/// scores exactly those two — the rest of the skill is judgement about page
/// content the tap never sees, and a heuristic over it would be a number that
/// looks like a measurement and is not one.
///
/// - The ranking rule. Every URL a fetch was sent to is classified against a
/// short allow-list of primary hosts and a short skip-list of aggregators.
/// Fetching an aggregator is the visible violation; fetching primary sources
/// is the visible compliance. Anything unrecognised is unranked and decides
/// nothing.
/// - The date rule. On mission `01a09b42` the parent's spawn prompts read
/// "Return the URL, date if visible, and the key content" — the task never
/// asked for a date; the skill's "undated is a finding" did. Reported as
/// extra evidence on a pass, never required for one: a curl to an abstract
/// page has no prompt to ask in.
///
/// One-sided like every check here: it reports a violation it can see and never
/// infers compliance from silence.
fn triage_compliance(ev: &Evidence<'_>) -> Verdict {
if ev.tools.is_empty() {
return Verdict::NotObservable(
"no tool calls were recorded for this mission — which URLs were \
fetched is what this check reads, and that is not in the narrative"
.into(),
);
}
let urls = fetched_urls(ev);
if urls.is_empty() {
// Never swept the web, so there was nothing to triage.
return Verdict::NotApplicable;
}
if let Some(u) = urls.iter().find(|u| is_aggregator(u)) {
return Verdict::Fail(format!(
"fetched {u} — an aggregator, rank 3 or below on the skill's ladder; \
the procedure is to find the primary source and record the rest as \
one item, not to read them"
));
}
let primary = urls.iter().filter(|u| is_primary_source(u)).count();
if primary == 0 {
return Verdict::NotObservable(format!(
"fetched {} URL(s), none on the primary-source list and none on the \
aggregator list — the check cannot rank them, and a rank it cannot \
see is not a violation",
urls.len()
));
}
let asked_for_date = ev
.tools
.iter()
.filter(|t| t.tool == "Agent")
.filter(|t| {
t.input
.get("prompt")
.and_then(serde_json::Value::as_str)
.is_some_and(|p| p.to_ascii_lowercase().contains("date"))
})
.count();
let mut why = format!(
"fetched {primary} primary source(s) out of {} URL(s) and no aggregator",
urls.len()
);
if asked_for_date > 0 {
why.push_str(&format!(
"; {asked_for_date} fetch(es) delegated to a subagent asked for the \
page's date, which the task did not and the skill does"
));
}
Verdict::PassWith(why)
}
/// `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(ev: &Evidence<'_>) -> Verdict {
// The query endpoint, not the word "arxiv" — and not `arxiv.org/abs/`
// either, which the same skill explicitly tells the agent to `curl`. A
// check that could not tell those apart would fail agents for following
// the paragraph directly under the one it is scoring.
const QUERY_MARKERS: &[&str] = &[
"export.arxiv.org/api/query",
"arxiv.org/api/query",
"http://export.arxiv.org",
];
let hit = |s: &str| QUERY_MARKERS.iter().find(|m| s.contains(**m)).copied();
// Arguments first: a `curl` that ran is a fact, where a narrative that
// mentions a URL may be the agent explaining that it did NOT fetch it.
for t in ev.tools {
let text = t.input.to_string();
if let Some(m) = hit(&text) {
return Verdict::Fail(format!(
"called {} against the arXiv API ({m}) — the harvest already \
ran, and shelving papers outside it corrupts the seen-set",
t.tool
));
}
}
if let Some(m) = hit(ev.text) {
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');
}
}
// What the agent DID, alongside what it said. A mission that ran before
// the tool tap existed returns an empty list, and the checks degrade to
// the narrative rather than reporting a violation they cannot see.
let tools = crate::mission_events::tool_evidence_for_mission(pool, mission_id)
.await
.map_err(|e| format!("read tool evidence: {e}"))?;
// 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, &Evidence::new(&outputs, &tools), &|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::*;
use crate::mission_events::ToolEvidence;
use serde_json::json;
fn builtin(_: &str) -> String {
"builtin".to_string()
}
/// Narrative-only evidence — a mission whose events predate the tool tap.
fn narrative(text: &str) -> Evidence<'_> {
Evidence::from_text(text)
}
/// Evidence from recorded ACTIONS. `(tool, path, input)`.
fn acted(calls: &[(&str, Option<&str>, serde_json::Value)]) -> Vec<ToolEvidence> {
calls
.iter()
.map(|(tool, path, input)| ToolEvidence {
tool: (*tool).to_string(),
path: path.map(str::to_string),
input: input.clone(),
response: serde_json::Value::Null,
})
.collect()
}
/// A `Bash` call that ran a test and reported an outcome.
fn test_run(cmd: &str, stdout: &str) -> ToolEvidence {
ToolEvidence {
tool: "Bash".into(),
path: None,
input: json!({ "command": cmd }),
response: json!({ "stdout": stdout, "stderr": "" }),
}
}
/// One `Bash` call.
fn ran(cmd: &str) -> (&'static str, Option<&'static str>, serde_json::Value) {
("Bash", None, serde_json::json!({ "command": cmd }))
}
/// 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),
crate::skill_delivery::Mode::Inline,
)
}
/// A prompt as the delivery layer renders it under the INDEX arm.
fn rendered_index(skills: &[(&str, &str)]) -> String {
let body: String = skills
.iter()
.map(|(name, when)| {
crate::topology_exec::render_pinned_skill(
name,
&crate::skill_delivery::index_entry(
"a procedure",
Some(when),
&format!("skill:global/{name}"),
),
)
})
.collect();
crate::topology_exec::compose_turn_prompt(
"Task: do the thing",
Some(&body),
crate::skill_delivery::Mode::Index,
)
}
fn read_skill(uri: &str) -> ToolEvidence {
ToolEvidence {
tool: "ReadMcpResourceTool".into(),
path: None,
input: json!({ "server": "clawmates_skills", "uri": uri }),
response: serde_json::Value::Null,
}
}
fn rendered_files(skills: &[(&str, &str)]) -> String {
let body: String = skills
.iter()
.map(|(name, when)| {
crate::topology_exec::render_pinned_skill(
name,
&crate::skill_delivery::file_entry(
"a procedure",
Some(when),
&crate::skill_delivery::skill_file_path(name),
),
)
})
.collect();
format!(
"Task: x\n\n# Your skills\n\n{}\n{body}",
crate::skill_delivery::FILES_PREAMBLE
)
}
fn read_file(path: &str) -> ToolEvidence {
ToolEvidence {
tool: "Read".into(),
path: Some(path.into()),
input: json!({ "file_path": path }),
response: serde_json::Value::Null,
}
}
/// The `files` arm's loop, end to end, for the same reason as the uri
/// test below: `skill_file_path` writes the path, `file_entry` puts it in
/// the prompt, `skill_from_file_path` reads it back off a `Read`.
#[test]
fn the_path_the_files_arm_advertises_is_the_one_the_scorer_recovers() {
let prompt = rendered_files(&[("workspace-repo-commit-protocol", "before committing")]);
assert!(
prompt.contains("Read(file_path=\"/mission/skills/workspace-repo-commit-protocol.md\")"),
"the entry must name the file to read:\n{prompt}"
);
assert_eq!(
crate::skill_delivery::mode_in_prompt(&prompt),
crate::skill_delivery::Mode::Files
);
let tools = vec![read_file("/mission/skills/workspace-repo-commit-protocol.md")];
let ev = Evidence::new("", &tools);
assert_eq!(retrieved_skills(&ev), vec!["workspace-repo-commit-protocol"]);
let scored = score(&prompt, &ev, &builtin);
assert_eq!(scored.len(), 1);
assert!(
matches!(scored[0].trigger, Verdict::Pass),
"a Read of the advertised path IS the Trigger axis: {:?}",
scored[0].trigger
);
}
/// Agents read files constantly. Only a `Read` INSIDE the skills directory
/// is a retrieval; anything else scoring as one would make Trigger a count
/// of file reads.
#[test]
fn an_ordinary_read_is_not_a_retrieval() {
let tools = vec![
read_file("/mission/repo/research/REPORT.md"),
read_file("/mission/skills"),
read_file("/mission/skills/nested/x.md"),
read_file("/etc/passwd"),
];
assert!(retrieved_skills(&Evidence::new("", &tools)).is_empty());
}
/// Under `files`, never opened is a miss when the skill had a checkable
/// consequence — the same rule as `index`, and NOT `inline`'s
/// "not observable", which would report the arm's own defect as a blind spot.
#[test]
fn the_files_arm_scores_a_miss_like_the_index_arm() {
let prompt = rendered_files(&[("int-xx-marker-protocol", "when writing markers")]);
let tools: Vec<ToolEvidence> = vec![];
let ev = Evidence::new("INT-01 something without the required shape", &tools);
let scored = score(&prompt, &ev, &builtin);
assert_eq!(scored.len(), 1);
assert!(
!matches!(scored[0].trigger, Verdict::NotObservable(_)),
"files is a retrieval arm; a miss must not read as inline: {:?}",
scored[0].trigger
);
}
fn bash(cmd: &str) -> ToolEvidence {
ToolEvidence {
tool: "Bash".into(),
path: None,
input: json!({ "command": cmd }),
response: serde_json::Value::Null,
}
}
fn spawn(prompt: &str) -> ToolEvidence {
ToolEvidence {
tool: "Agent".into(),
path: None,
input: json!({ "prompt": prompt, "subagent_type": "general-purpose" }),
response: serde_json::Value::Null,
}
}
/// The shape mission 01a09b42 recorded: the parent read the skill, then
/// sent each primary source to a subagent and asked for the date — which
/// the task never did and the skill's "undated is a finding" does.
#[test]
fn triage_passes_on_primary_sources_and_reports_the_date_fingerprint() {
let tools = vec![
spawn(
"Fetch the following URLs and return their full text content. Return the \
URL, date if visible, and the key content.\n1. https://arxiv.org/abs/2309.15217 \
(RAGAS paper)\n2. https://arxiv.org/abs/2311.09476",
),
bash("curl -s \"https://arxiv.org/abs/2309.01431\" | head -200"),
];
let ev = Evidence::new("", &tools);
match triage_compliance(&ev) {
Verdict::PassWith(why) => {
assert!(why.contains("3 primary source(s)"), "{why}");
assert!(why.contains("asked for the page's date"), "{why}");
}
other => panic!("expected a pass with evidence, got {other:?}"),
}
}
/// The shape the `index` runs recorded — inline curls, no delegation. The
/// agent still read primary sources, so it still complied; the date
/// fingerprint is extra evidence, never a requirement.
#[test]
fn triage_passes_on_inline_curls_without_the_date_clause() {
let tools = vec![
bash("curl -sL https://arxiv.org/abs/2204.04745"),
bash("ls -la research/"),
];
match triage_compliance(&Evidence::new("", &tools)) {
Verdict::PassWith(why) => assert!(!why.contains("date"), "{why}"),
other => panic!("{other:?}"),
}
}
/// The one violation the check can see: reading a restatement of a
/// restatement instead of the source it restates.
#[test]
fn triage_fails_on_an_aggregator() {
let tools = vec![
bash("curl -s https://arxiv.org/abs/2309.15217"),
spawn("Fetch https://medium.com/@someone/rag-eval-explained-2024 and summarise"),
];
match triage_compliance(&Evidence::new("", &tools)) {
Verdict::Fail(why) => assert!(why.contains("medium.com"), "{why}"),
other => panic!("{other:?}"),
}
}
/// One-sided, both ways. Nothing fetched is nothing to triage; something
/// fetched that the lists cannot rank is unranked, not a violation.
#[test]
fn triage_is_silent_where_it_cannot_see() {
let none: Vec<ToolEvidence> = vec![];
assert!(matches!(
triage_compliance(&Evidence::new("", &none)),
Verdict::NotObservable(_)
));
let no_fetch = vec![bash("cargo test"), bash("git status")];
assert!(matches!(
triage_compliance(&Evidence::new("", &no_fetch)),
Verdict::NotApplicable
));
let unranked = vec![bash("curl -s https://docs.example-vendor.io/eval/guide")];
assert!(matches!(
triage_compliance(&Evidence::new("", &unranked)),
Verdict::NotObservable(_)
));
}
/// A URL inside JSON is followed by a quote, and one at the end of a
/// sentence by a full stop. Neither is part of the URL.
#[test]
fn fetched_urls_stop_at_the_right_character() {
let tools = vec![spawn(
"Fetch \"https://arxiv.org/abs/1\" then https://doi.org/10.1/x. Done.",
)];
assert_eq!(
fetched_urls(&Evidence::new("", &tools)),
vec!["https://arxiv.org/abs/1", "https://doi.org/10.1/x"]
);
}
/// `PassWith` must be indistinguishable from `Pass` to a reader keyed on
/// the tag, or every consumer of the report grows a fourth branch.
#[test]
fn a_pass_with_evidence_serialises_under_the_pass_tag() {
let v = serde_json::to_value(Verdict::PassWith("saw it".into())).unwrap();
assert_eq!(v["verdict"], "pass");
assert_eq!(v["why"], "saw it");
assert_eq!(Verdict::PassWith("x".into()).label(), Verdict::Pass.label());
}
/// The whole loop, end to end: the index writes a uri, the agent reads that
/// exact uri back, and the scorer recovers the skill's name from it.
///
/// Three components have to agree on one string — `mcp_skills::skill_uri`
/// writes it, `skill_delivery::index_entry` puts it in the prompt, and
/// `parse_uri` reads it. Asserting them separately would let any pair drift
/// while each one's own test stayed green.
#[test]
fn the_uri_the_index_advertises_is_the_one_the_scorer_recovers() {
let prompt = rendered_index(&[("workspace-repo-commit-protocol", "before committing")]);
assert!(
prompt.contains("uri=\"skill:global/workspace-repo-commit-protocol\""),
"the entry must name the uri to fetch:\n{prompt}"
);
let tools = vec![read_skill("skill:global/workspace-repo-commit-protocol")];
let ev = Evidence::new("", &tools);
assert_eq!(
retrieved_skills(&ev),
vec!["workspace-repo-commit-protocol"],
"the uri the prompt advertised must parse back to the skill's name"
);
let scored = score(&prompt, &ev, &builtin);
assert_eq!(scored.len(), 1);
assert!(
matches!(scored[0].trigger, Verdict::Pass),
"reaching for an indexed skill IS the Trigger axis: {:?}",
scored[0].trigger
);
}
/// The index arm must not report a miss as a blind spot.
///
/// Under `Inline` "never retrieved" is `NotObservable`, and that is honest
/// there. Reusing it here would say "this skill was inlined into the
/// prompt" about a skill whose body was never sent — the exact shape of a
/// check reporting a system defect where an agent behaviour belongs.
#[test]
fn an_indexed_skill_that_was_never_read_is_a_miss_not_a_blind_spot() {
let prompt = rendered_index(&[("workspace-repo-commit-protocol", "before committing")]);
// It wrote outside the mission checkout, so the boundary check applies
// — this skill had something to say about this phase and went unread.
let tools = acted(&[("Write", Some("/tmp/scratch.rs"), json!({}))]);
let scored = score(&prompt, &Evidence::new("", &tools), &builtin);
assert!(
matches!(scored[0].trigger, Verdict::Fail(_)),
"offered by name and when_to_use, never fetched: {:?}",
scored[0].trigger
);
}
/// ...but only when the skill had a consequence to check.
///
/// A skill with nothing machine-checkable in this phase is one an agent is
/// right to pass over, and scoring that as a Trigger failure would punish
/// correct triage — which is the behaviour progressive disclosure is
/// supposed to reward.
#[test]
fn passing_over_a_skill_with_nothing_to_check_is_not_a_trigger_failure() {
let prompt = rendered_index(&[("some-unchecked-skill", "when writing prose")]);
let scored = score(&prompt, &narrative("wrote the report"), &builtin);
assert!(
matches!(scored[0].trigger, Verdict::NotApplicable),
"{:?}",
scored[0].trigger
);
}
/// The control arm has to be unchanged, or the A/B measures this edit too.
/// The case this whole flag exists for.
///
/// `workspace-repo-commit-protocol` applies to every agent that writes,
/// which is exactly why no agent reads it as *theirs* — it scored
/// Trigger=FAIL beside a passing boundary check on the first A/B pair.
/// Marked `always_inject`, its body is in the prompt under the index arm,
/// and a skill the agent was handed cannot be a reaching-for failure.
#[test]
fn a_skill_delivered_in_full_under_the_index_arm_is_not_a_trigger_miss() {
let prompt = format!(
"{}\n{}{}",
crate::skill_delivery::INDEX_PREAMBLE,
crate::topology_exec::render_pinned_skill(
"workspace-repo-commit-protocol",
"Commit only inside /workspace/repo. Never write outside it.",
),
rendered_index(&[("arxiv-daily", "when sweeping arxiv")]),
);
let tools = [];
let ev = Evidence { text: "", tools: &tools };
let got = score(&prompt, &ev, &|_| "builtin".to_string());
let it = got
.iter()
.find(|u| u.skill == "workspace-repo-commit-protocol")
.expect("the always-injected skill must still be scored");
assert!(
matches!(it.trigger, Verdict::NotObservable(_)),
"handed over, not offered — there is no retrieval to miss: {:?}",
it.trigger
);
}
/// The flag must not leak: a skill still delivered as an index entry keeps
/// being scored on whether it was fetched.
#[test]
fn an_indexed_skill_in_the_same_prompt_is_still_judged_on_retrieval() {
let prompt = format!(
"{}\n{}{}",
crate::skill_delivery::INDEX_PREAMBLE,
crate::topology_exec::render_pinned_skill(
"workspace-repo-commit-protocol",
"Commit only inside /workspace/repo.",
),
rendered_index(&[("arxiv-daily", "when sweeping arxiv")]),
);
for u in score(&prompt, &Evidence { text: "", tools: &[] }, &|_| "builtin".into()) {
if u.skill != "workspace-repo-commit-protocol" {
assert!(
!matches!(u.trigger, Verdict::NotObservable(_)),
"{} was offered by uri, so retrieval is observable for it",
u.skill
);
}
}
}
#[test]
fn the_inline_arm_still_reports_trigger_as_unobservable() {
let prompt = rendered(&[("workspace-repo-commit-protocol", "body")]);
let tools = acted(&[("Write", Some("/tmp/scratch.rs"), json!({}))]);
let scored = score(&prompt, &Evidence::new("", &tools), &builtin);
assert!(
matches!(scored[0].trigger, Verdict::NotObservable(_)),
"{:?}",
scored[0].trigger
);
}
/// 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", &narrative("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, &narrative("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, &narrative(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, &narrative(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, &narrative("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, both since fixed in the parser rather than
/// worked around here:
///
/// 1. `PLAN_COMPLETE` was documented in `int-xx-marker-protocol` and never
/// implemented, so an agent following the skill exactly was ignored. It
/// is implemented now.
/// 2. The range form parsed into the id `INT-01..02`, matching no real
/// item — a task card for something that did not exist. Ids are now
/// strictly `INT-<digits>`, so the marker is REJECTED instead, which is
/// visible where a plausible-looking row was not.
#[test]
fn plan_complete_is_now_a_real_marker() {
let parsed = crate::task_card_parser::parse("PLAN_COMPLETE: INT-01");
assert_eq!(parsed.len(), 1, "the skill documents it; the parser must accept it");
assert_eq!(parsed[0].int_id, "INT-01");
let prompt = rendered(&[("int-xx-marker-protocol", "Emit markers.")]);
assert_eq!(
score(&prompt, &narrative("PLAN_COMPLETE: INT-01"), &builtin)[0].compliance,
Verdict::Pass
);
}
/// The exact line a live planner emitted.
#[test]
fn a_range_marker_is_rejected_rather_than_creating_a_phantom_item() {
assert!(
crate::task_card_parser::parse("PLAN_COMPLETE: INT-01..02").is_empty(),
"a range must not parse — it produced a task card for an item that \
does not exist while INT-01 and INT-02 stayed open"
);
assert!(crate::task_card_parser::parse("COMPLETED: INT-01..02").is_empty());
// And the agent is told, because the marker it emitted did nothing.
let prompt = rendered(&[("int-xx-marker-protocol", "Emit markers.")]);
match &score(&prompt, &narrative("PLAN_COMPLETE: INT-01..02"), &builtin)[0].compliance {
Verdict::Fail(why) => assert!(why.contains("does not accept")),
other => panic!("an ignored marker must not read as success; got {other:?}"),
}
}
#[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, &narrative("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,
&narrative("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,
&narrative("I read the arXiv notes in the manifest and summarised three of them."),
&builtin,
);
assert_eq!(fine[0].boundary, Verdict::Pass);
}
/// Trigger, from the exact arguments a live mission recorded.
///
/// Run 8, 2026-08-21: three agents each reached through the door and read a
/// skill body. Copied from `mission_events`, not invented — the shape of
/// this payload is the whole basis of the axis.
#[test]
fn a_retrieved_skill_scores_trigger_as_a_pass() {
let tools = acted(&[
("ListMcpResourcesTool", None, json!({})),
(
"ReadMcpResourceTool",
None,
json!({"uri": "skill:global/workspace-repo-commit-protocol",
"server": "clawmates_skills"}),
),
]);
let ev = Evidence::new("", &tools);
assert_eq!(
retrieved_skills(&ev),
vec!["workspace-repo-commit-protocol"]
);
// Delivered by RETRIEVAL only — nothing in the prompt at all. Under
// progressive disclosure this is every skill, so a scorer that reads
// only the prompt would report nothing for the delivery model this
// axis exists to measure.
let scored = score("Task: do the thing", &ev, &builtin);
assert_eq!(scored.len(), 1);
assert_eq!(scored[0].skill, "workspace-repo-commit-protocol");
assert_eq!(
scored[0].trigger,
Verdict::Pass,
"the agent reached for it; that is the paper's Trigger"
);
}
/// An inlined skill is still `NotObservable`, and the reason now names the
/// fix rather than the transport.
#[test]
fn an_inlined_skill_is_still_unobservable_for_trigger() {
let prompt = rendered(&[("arxiv-daily", "x")]);
let scored = score(&prompt, &narrative("did it"), &builtin);
match &scored[0].trigger {
Verdict::NotObservable(why) => assert!(
why.contains("handed it"),
"the reason must say WHY it cannot be seen — being handed a \
skill is not failing to reach for one: {why}"
),
other => panic!("got {other:?}"),
}
}
/// A workspace-scoped URI resolves to the same name as a global one.
#[test]
fn both_uri_shapes_yield_the_skill_name() {
let ws = uuid::Uuid::now_v7();
let tools = acted(&[
(
"ReadMcpResourceTool",
None,
json!({"uri": format!("skill:workspace/{ws}/team-local-thing")}),
),
// Not a skill URI, and not a panic.
("ReadMcpResourceTool", None, json!({"uri": "file:///etc/passwd"})),
// A LIST call names no skill — only reads are retrievals.
("ListMcpResourcesTool", None, json!({})),
]);
assert_eq!(
retrieved_skills(&Evidence::new("", &tools)),
vec!["team-local-thing"],
"listing the catalogue is browsing; reading a body is the reach"
);
}
/// Agent-authored skills must stay visible as such in the report.
#[test]
fn writing_outside_the_mission_checkout_crosses_the_boundary() {
let prompt = rendered(&[("workspace-repo-commit-protocol", "Work in /mission/repo.")]);
let wrong = score(
&prompt,
&narrative("cd /workspace/repo && git add -A && git commit -m 'INT-01 done'"),
&builtin,
);
match &wrong[0].boundary {
Verdict::Fail(why) => assert!(
why.contains("never collected"),
"the failure must name the consequence — a phase that delivers \
nothing — not just the wrong path: {why}"
),
other => panic!("the wrong checkout must be caught; got {other:?}"),
}
let right = score(
&prompt,
&narrative("cd /mission/repo && git add -A && git commit -m 'INT-01 done'"),
&builtin,
);
assert_eq!(right[0].boundary, Verdict::Pass);
}
/// The upgrade, stated as a test: the agent never said where it wrote.
///
/// Before the tool tap this scored a clean `Pass`, because the only
/// evidence was the narrative and the narrative did not mention a path. The
/// phase had still delivered nothing.
#[test]
fn a_write_outside_the_checkout_is_caught_even_when_the_narrative_is_silent() {
let prompt = rendered(&[("workspace-repo-commit-protocol", "Work in /mission/repo.")]);
let tools = acted(&[
("Write", Some("/app/src/main.rs"), json!({"file_path": "/app/src/main.rs"})),
]);
let scored = score(&prompt, &Evidence::new("I implemented the feature.", &tools), &builtin);
match &scored[0].boundary {
Verdict::Fail(why) => assert!(why.contains("/app/src/main.rs"), "{why}"),
other => panic!("the write is right there in the record; got {other:?}"),
}
}
/// Scratch is not a violation — the skill blesses it by name.
#[test]
fn scratch_and_the_checkout_both_stay_inside_the_boundary() {
let prompt = rendered(&[("workspace-repo-commit-protocol", "Work in /mission/repo.")]);
let tools = acted(&[
("Write", Some("/tmp/scratch.txt"), json!({"file_path": "/tmp/scratch.txt"})),
("Write", Some("/mission/repo/src/a.rs"), json!({"file_path": "/mission/repo/src/a.rs"})),
]);
let scored = score(&prompt, &Evidence::new("done", &tools), &builtin);
assert_eq!(scored[0].boundary, Verdict::Pass);
}
/// Nothing observed is not the same as nothing done.
///
/// This returned `Pass` before — a mission with no evidence scored
/// identically to one that was checked and found clean, which is the exact
/// collapse `Verdict::NotObservable` exists to prevent.
#[test]
fn silence_is_not_scored_as_compliance() {
let prompt = rendered(&[("workspace-repo-commit-protocol", "Work in /mission/repo.")]);
let scored = score(&prompt, &narrative("I thought about the problem."), &builtin);
assert!(
matches!(scored[0].boundary, Verdict::NotObservable(_)),
"got {:?}",
scored[0].boundary
);
}
#[test]
fn a_force_push_crosses_the_boundary_and_a_quoted_flag_does_not() {
let prompt = rendered(&[("workspace-repo-commit-protocol", "Work in /mission/repo.")]);
let forced = acted(&[
("Write", Some("/mission/repo/a.rs"), json!({"file_path": "/mission/repo/a.rs"})),
ran("cd /mission/repo && git push --force origin mission"),
]);
match &score(&prompt, &Evidence::new("", &forced), &builtin)[0].boundary {
Verdict::Fail(why) => assert!(why.contains("force-push"), "{why}"),
other => panic!("rewriting pushed history is the one unrecoverable act; got {other:?}"),
}
// The flag inside a commit message rewrites nothing.
let innocent = acted(&[
("Write", Some("/mission/repo/a.rs"), json!({"file_path": "/mission/repo/a.rs"})),
ran(r#"git commit -m "document the -f flag" && git push origin mission"#),
]);
assert_eq!(
score(&prompt, &Evidence::new("", &innocent), &builtin)[0].boundary,
Verdict::Pass
);
}
/// Writing code and never running a test cannot be red-green-refactor under
/// any reading of the loop.
#[test]
fn code_written_with_no_test_run_fails_the_loop() {
let prompt = rendered(&[("tdd-red-green-refactor", "Red, green, refactor.")]);
let tools = acted(&[
("Write", Some("/mission/repo/src/a.rs"), json!({"file_path": "/mission/repo/src/a.rs"})),
ran("cargo build"),
]);
match &score(&prompt, &Evidence::new("Implemented it.", &tools), &builtin)[0].compliance {
Verdict::Fail(why) => assert!(why.contains("never ran a test"), "{why}"),
other => panic!("got {other:?}"),
}
}
#[test]
fn a_test_run_before_the_first_write_is_red_first() {
let prompt = rendered(&[("cargo-test-driven-development", "Red, green, refactor.")]);
let tools = acted(&[
ran("cargo nextest run parses_the_range_form"),
("Write", Some("/mission/repo/src/a.rs"), json!({"file_path": "/mission/repo/src/a.rs"})),
ran("cargo nextest run parses_the_range_form"),
]);
assert_eq!(
score(&prompt, &Evidence::new("", &tools), &builtin)[0].compliance,
Verdict::Pass
);
}
/// The ordering that cannot be read either way, reported as such.
///
/// A Rust unit test lives in `#[cfg(test)] mod tests` inside the file under
/// test, so writing the failing test IS a write to the implementation file.
/// Scoring this as a failure would fail the agents who followed the skill
/// most precisely.
#[test]
fn a_test_run_after_the_first_write_is_undecidable_not_a_failure() {
let prompt = rendered(&[("tdd-red-green-refactor", "Red, green, refactor.")]);
let tools = acted(&[
("Write", Some("/mission/repo/src/a.rs"), json!({"file_path": "/mission/repo/src/a.rs"})),
ran("cargo test -p cm-api"),
]);
assert!(
matches!(
score(&prompt, &Evidence::new("", &tools), &builtin)[0].compliance,
Verdict::NotObservable(_)
),
"the Rust unit test lives in the file under test — this ordering \
cannot separate red-first from tests-added-after"
);
}
/// Red then green, read from what the runs themselves reported.
///
/// This is the case ordering cannot decide and the repository cannot
/// either: one `Edit` adds the implementation and its `#[cfg(test)] mod
/// tests` together, and `tdd-red-green-refactor` says in so many words to
/// "commit the RED-to-GREEN pair as one commit". The run outputs are the
/// only witness left.
#[test]
fn a_failing_run_followed_by_a_passing_one_is_red_then_green() {
let prompt = rendered(&[("tdd-red-green-refactor", "Red, green, refactor.")]);
let mut tools = acted(&[(
"Write",
Some("/mission/repo/src/a.rs"),
json!({"file_path": "/mission/repo/src/a.rs"}),
)]);
tools.push(test_run(
"cargo test",
"running 1 test\ntest slugs ... FAILED\n\ntest result: FAILED. 0 passed; 1 failed",
));
tools.push(test_run(
"cargo test",
"running 1 test\ntest slugs ... ok\n\ntest result: ok. 1 passed; 0 failed",
));
assert_eq!(
score(&prompt, &Evidence::new("", &tools), &builtin)[0].compliance,
Verdict::Pass
);
}
/// Every run green is NOT evidence of red-first.
#[test]
fn runs_that_only_ever_passed_cannot_prove_the_loop() {
let prompt = rendered(&[("tdd-red-green-refactor", "Red, green, refactor.")]);
let mut tools = acted(&[(
"Write",
Some("/mission/repo/src/a.rs"),
json!({"file_path": "/mission/repo/src/a.rs"}),
)]);
tools.push(test_run("cargo test", "test result: ok. 3 passed; 0 failed"));
match &score(&prompt, &Evidence::new("", &tools), &builtin)[0].compliance {
Verdict::NotObservable(why) => assert!(
why.contains("never failed"),
"a test that never failed is equally what a correct \
implementation written first looks like: {why}"
),
other => panic!("got {other:?}"),
}
}
/// Ending red is a failure of the loop, and a different one from never
/// running a test at all.
#[test]
fn a_phase_that_never_reached_green_fails() {
let prompt = rendered(&[("tdd-red-green-refactor", "Red, green, refactor.")]);
let mut tools = acted(&[(
"Write",
Some("/mission/repo/src/a.rs"),
json!({"file_path": "/mission/repo/src/a.rs"}),
)]);
tools.push(test_run("cargo test", "test result: FAILED. 0 passed; 2 failed"));
match &score(&prompt, &Evidence::new("", &tools), &builtin)[0].compliance {
Verdict::Fail(why) => assert!(why.contains("never got there"), "{why}"),
other => panic!("got {other:?}"),
}
}
/// A mission recorded before responses were captured must not read as
/// either verdict.
#[test]
fn runs_with_no_recorded_output_stay_unobservable() {
let prompt = rendered(&[("tdd-red-green-refactor", "Red, green, refactor.")]);
let tools = acted(&[
("Write", Some("/mission/repo/src/a.rs"), json!({"file_path": "/mission/repo/src/a.rs"})),
("Bash", None, json!({"command": "cargo test"})),
]);
assert!(matches!(
score(&prompt, &Evidence::new("", &tools), &builtin)[0].compliance,
Verdict::NotObservable(_)
));
}
/// The verdict line is at the END of a test run, which is why the response
/// bound keeps the tail.
#[test]
fn a_truncated_response_still_carries_the_verdict() {
let long = "compiling…\n".repeat(400) + "test result: FAILED. 0 passed; 1 failed";
let bounded = crate::vm_tool_tap::bounded_response(
"Bash",
&json!({ "stdout": long, "stderr": "" }),
);
let kept = bounded["stdout"].as_str().expect("stdout kept");
assert!(
kept.contains("test result: FAILED"),
"a head-biased truncation would keep the noise and drop the verdict, \
which is the only reason the output is stored"
);
assert!(kept.starts_with("[truncated]"), "{kept}");
}
/// A `Read`'s response is the file it just read — already knowable, and
/// large.
#[test]
fn only_commands_carry_a_response() {
assert_eq!(
crate::vm_tool_tap::bounded_response("Read", &json!({"stdout": "x"})),
serde_json::Value::Null
);
}
/// A phase that wrote nothing had no implementation to test-drive.
/// The verdict the first live run got wrong.
///
/// A research phase wrote fifteen markdown notes and ran no test, and the
/// check called it a TDD failure. There was no code to test-drive. The
/// real finding is that a research role is pinned TDD skills at all, and
/// scoring the agent for it would have buried that.
#[test]
fn writing_prose_is_not_a_tdd_failure() {
let prompt = rendered(&[("tdd-red-green-refactor", "Red, green, refactor.")]);
let tools = acted(&[
("Write", Some("/mission/repo/research/notes.md"), json!({"file_path": "/mission/repo/research/notes.md"})),
("Edit", Some("/mission/repo/research/notes.md"), json!({"file_path": "/mission/repo/research/notes.md"})),
("Write", Some("/mission/repo/research/check.sh"), json!({"file_path": "/mission/repo/research/check.sh"})),
]);
assert_eq!(
score(&prompt, &Evidence::new("", &tools), &builtin)[0].compliance,
Verdict::NotApplicable,
"markdown and a helper script are not the behaviour-adding code \
this loop is about"
);
}
#[test]
fn a_phase_that_wrote_nothing_is_not_a_tdd_failure() {
let prompt = rendered(&[("tdd-red-green-refactor", "Red, green, refactor.")]);
let tools = acted(&[ran("ls /mission/repo"), ("Read", Some("/mission/repo/a.rs"), json!({}))]);
assert_eq!(
score(&prompt, &Evidence::new("", &tools), &builtin)[0].compliance,
Verdict::NotApplicable
);
}
#[test]
fn the_never_merge_subjects_are_caught_and_a_real_one_is_not() {
let prompt = rendered(&[("small-focused-commits", "One idea per commit.")]);
let bad = acted(&[ran(r#"git commit -m "wip""#)]);
match &score(&prompt, &Evidence::new("", &bad), &builtin)[0].boundary {
Verdict::Fail(why) => assert!(why.contains("never-merge"), "{why}"),
other => panic!("got {other:?}"),
}
// The objection is to `fix` as the whole message, not to the word.
let good = acted(&[ran(r#"git commit -m "INT-04 fix the range form the parser rejects""#)]);
assert_eq!(
score(&prompt, &Evidence::new("", &good), &builtin)[0].boundary,
Verdict::NotApplicable
);
}
/// The subject comes out of the shell command, in the shapes agents write.
#[test]
fn the_commit_subject_is_read_out_of_the_real_command_shapes() {
assert_eq!(dash_m_value(r#"git commit -m "one line""#).as_deref(), Some("one line"));
assert_eq!(dash_m_value("git commit -am 'clustered flag'").as_deref(), Some("clustered flag"));
// The template in `workspace-repo-commit-protocol` spans several lines;
// only the first is the subject.
let multi = "cd /mission/repo && git commit -m \"INT-01 wire the loop\n\nWHY: it stalled.\n\nRefs: INT-01\n\"";
assert_eq!(
commit_subjects(&Evidence::new("", &acted(&[ran(multi)]))),
vec!["INT-01 wire the loop"]
);
// `-F` gives up nothing, and that must read as no evidence.
assert!(dash_m_value("git commit -F /tmp/msg").is_none());
}
/// The exact commit command the first live coding run ran.
///
/// Claude Code writes a multi-line message as a heredoc inside a command
/// substitution, so the first line of the `-m` value is the heredoc opener
/// and the subject is the line after it. Read from
/// `mission_events`, not invented.
#[test]
fn a_heredoc_commit_message_gives_up_its_real_subject() {
let live = "git add src/lib.rs && git commit -m \"$(cat <<'EOF'\n\
INT-01 Add slugify function to src/lib.rs\n\n\
The crate exposed only add() with no string-normalization utility.\n\n\
Refs: INT-01\n\
EOF\n\
)\"";
assert_eq!(
commit_subjects(&Evidence::new("", &acted(&[ran(live)]))),
vec!["INT-01 Add slugify function to src/lib.rs"],
"the heredoc OPENER is not the subject — reading it as one puts \
`$(cat <<'EOF'` in the record, and every commit check then scores \
a string the agent never wrote"
);
}
/// The skill tells the agent to `curl` the abstract page in the paragraph
/// under the one that forbids the query API. A check that cannot tell them
/// apart fails agents for obeying the skill.
#[test]
fn curling_an_abstract_is_allowed_where_querying_the_api_is_not() {
let prompt = rendered(&[("arxiv-daily", "The harvest already ran.")]);
let allowed = acted(&[ran("curl -s https://arxiv.org/abs/2401.12345")]);
assert_eq!(
score(&prompt, &Evidence::new("", &allowed), &builtin)[0].boundary,
Verdict::Pass
);
let forbidden = acted(&[ran("curl -s 'http://export.arxiv.org/api/query?search_query=all:agents'")]);
match &score(&prompt, &Evidence::new("", &forbidden), &builtin)[0].boundary {
Verdict::Fail(why) => assert!(why.contains("seen-set"), "{why}"),
other => panic!("got {other:?}"),
}
}
#[test]
fn the_source_of_a_skill_is_carried_into_its_score() {
let prompt = rendered(&[("self-made", "x")]);
let scored = score(&prompt, &narrative("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"
);
}
}