@ericrisco/rsc 1.0.13 → 1.0.14

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/manifest.json CHANGED
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "1.0.13",
2
+ "version": "1.0.14",
3
3
  "counts": {
4
4
  "skills": 264
5
5
  },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ericrisco/rsc",
3
- "version": "1.0.13",
3
+ "version": "1.0.14",
4
4
  "description": "Eric Risco's agent-skills catalog as a granular, self-recommending CLI installer.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -41,7 +41,25 @@ export function aggregate(scenarios) {
41
41
  return { absoluteScore, lift, n: valid.length, dropped };
42
42
  }
43
43
 
44
- export function behavioralGate(agg) {
44
+ // integrity is OPTIONAL and absent means "not checked", never "clean" — callers that predate the
45
+ // integrity layer (holdoutGate, raw stdin scoring) keep their exact behaviour.
46
+ //
47
+ // A violated integrity is BLOCKED, not FAIL, and the distinction is the whole point: FAIL says "the
48
+ // skill does not clear the bar" and sends someone to edit the skill. When the control read the
49
+ // answer key, the only true statement is "this run measures nothing" — a different action entirely.
50
+ export function behavioralGate(agg, integrity) {
51
+ if (integrity && integrity.ok === false) {
52
+ const vs = Array.isArray(integrity.violations) ? integrity.violations : [];
53
+ return {
54
+ pass: false,
55
+ blocked: true,
56
+ reasons: [integrity.reason || 'integrity check failed'],
57
+ mustFix: vs.length
58
+ ? vs.map((v) => `${v.kind}: ${v.agent} touched ${v.pattern} (x${v.count}) — re-run with the arm isolated.`)
59
+ : ['Re-run the eval with integrity verifiable; the scores from this run are not a measurement.'],
60
+ violations: vs,
61
+ };
62
+ }
45
63
  const reasons = [];
46
64
  const mustFix = [];
47
65
  if (!agg || agg.n === 0) {
@@ -118,7 +136,7 @@ export function formatHoldoutVerdict(scored, gate) {
118
136
  }
119
137
 
120
138
  // raw: { skillId, scenarios: [{index, xIsTreatment, gradeX, gradeY, error?}] }
121
- export function scoreFromRaw(raw) {
139
+ export function scoreFromRaw(raw, integrity) {
122
140
  const scenarios = (raw && Array.isArray(raw.scenarios) ? raw.scenarios : []).map((s) => {
123
141
  if (!s || s.error || !s.gradeX || !s.gradeY) {
124
142
  return { index: s ? s.index : null, error: (s && s.error) || 'missing-grade', absolute: null, delta: null };
@@ -131,8 +149,9 @@ export function scoreFromRaw(raw) {
131
149
  return { index: s.index, treatment: t, baseline: b, absolute: d.absolute, delta: d.delta };
132
150
  });
133
151
  const agg = aggregate(scenarios);
134
- const gate = behavioralGate(agg);
152
+ const gate = behavioralGate(agg, integrity);
135
153
  const out = { skillId: (raw && raw.skillId) || null, scenarios, aggregate: agg, gate };
154
+ if (integrity) out.integrity = integrity;
136
155
  // Carried through untouched so the hold-out verdict can echo the fresh scenario for hand-audit.
137
156
  if (raw && raw.holdoutScenario) out.holdoutScenario = raw.holdoutScenario;
138
157
  return out;
@@ -143,7 +162,18 @@ export function formatScorecard(scored) {
143
162
  const lines = [];
144
163
  lines.push(`# Behavioral scorecard — ${skillId || '(unknown skill)'}`);
145
164
  lines.push('');
146
- lines.push(`**Verdict:** ${gate.pass ? 'PASS ✅' : 'FAIL ❌'} · absolute ${agg.absoluteScore == null ? 'n/a' : agg.absoluteScore}/10 (gate >= ${ABS_MIN}) · lift ${agg.lift == null ? 'n/a' : agg.lift} (gate >= ${LIFT_MIN}) · n=${agg.n}${agg.dropped ? ` (${agg.dropped} dropped)` : ''}`);
165
+ if (gate.blocked) {
166
+ // Deliberately NO absolute and NO lift here. Printing them beside a block invites reading them
167
+ // anyway, and they are not measurements — that is exactly what the block is saying.
168
+ lines.push(`**Verdict:** BLOCKED ⛔ · ${gate.reasons[0]}`);
169
+ lines.push('');
170
+ lines.push('The scores from this run are withheld: they are not a measurement of this skill.');
171
+ lines.push('');
172
+ lines.push('**Integrity violations:**');
173
+ for (const f of gate.mustFix) lines.push(`- ${f}`);
174
+ return lines.join('\n');
175
+ }
176
+ lines.push(`**Verdict:** ${gate.pass ? 'PASS ✅' : 'FAIL ❌'} · absolute ${agg.absoluteScore == null ? 'n/a' : agg.absoluteScore}/10 (gate >= ${ABS_MIN}) · lift ${agg.lift == null ? 'n/a' : agg.lift} (gate >= ${LIFT_MIN}) · n=${agg.n}${agg.dropped ? ` (${agg.dropped} dropped)` : ''}${scored.integrity ? ' · integrity verified' : ' · integrity NOT CHECKED (pass --transcripts)'}`);
147
177
  lines.push('');
148
178
  lines.push('| Scenario | Treatment | Baseline | Delta |');
149
179
  lines.push('|---|---|---|---|');
@@ -0,0 +1,154 @@
1
+ // eval-integrity.js — is a behavioral eval run measuring anything at all?
2
+ //
3
+ // skill-behavior-eval promises to run each capability scenario "with and without the skill". The
4
+ // "without" arm runs with full filesystem access inside this repo, where the skill under test IS a
5
+ // file, and nothing stopped it reading that file. Measured in the 2026-08-18 run: the `verify`
6
+ // baseline read skills/verify/SKILL.md 7 times AND skills/verify/evals/cases.yaml 4 times — the
7
+ // very must_include list it was being graded against. It then produced "SUSTITUIDA", a word that
8
+ // exists nowhere but the skill written the day before.
9
+ //
10
+ // A lift measured against a control that read the answer key is a lower bound of unknown size.
11
+ // Publishing it as a number is the claim-without-evidence this repo keeps paying for (P2), and it
12
+ // feeds skill-harden's --holdout, which decides what enters a 264-skill catalog.
13
+ //
14
+ // This module answers the question deterministically, from the transcripts themselves — never from
15
+ // what an agent says about its own behaviour, which is the way this fails open.
16
+ //
17
+ // Design note: the pure functions take text, the reader touches disk. That split is what makes the
18
+ // interesting logic testable without fixtures on disk.
19
+
20
+ import { readdirSync, readFileSync, statSync } from 'node:fs';
21
+ import { join } from 'node:path';
22
+
23
+ // Prompt markers the workflow actually emits. tests/eval-integrity.test.js asserts each of these
24
+ // still appears verbatim in scripts/skill-behavior-eval.workflow.js — without that tie, rewording a
25
+ // prompt silently retires the classifier and every run starts reporting "clean".
26
+ export const ROLE_MARKERS = {
27
+ grader: 'adversarial, independent grader',
28
+ treatment: '=== END SKILL ===',
29
+ loader: 'return its full text as',
30
+ };
31
+
32
+ // Write verbs that indicate a transcript actually WROTE a path rather than merely naming it. A
33
+ // mention is not a mutation: the grader quotes repo paths constantly and is not violating anything.
34
+ const WRITE_SIGNALS = [
35
+ 'Write', 'Edit', 'MultiEdit', 'NotebookEdit',
36
+ 'cat >', 'tee ', '>>', 'mv ', 'cp ', 'rm ',
37
+ ];
38
+
39
+ // Paths an eval arm may never write. 02-DOCS is the project's brain and is untracked (P9), so a
40
+ // stray write there has no undo.
41
+ export const PROTECTED_PATHS = ['02-DOCS/wiki/'];
42
+
43
+ /**
44
+ * Which arm produced this transcript. Order matters: a grader transcript quotes the skill body, so
45
+ * it would otherwise look like a treatment.
46
+ */
47
+ export function classifyAgent(transcriptText) {
48
+ const t = String(transcriptText || '');
49
+ if (t.includes(ROLE_MARKERS.grader)) return 'grader';
50
+ if (t.includes(ROLE_MARKERS.treatment)) return 'treatment';
51
+ if (t.includes(ROLE_MARKERS.loader)) return 'loader';
52
+ return 'baseline';
53
+ }
54
+
55
+ const countOf = (text, needle) => {
56
+ let n = 0;
57
+ let i = text.indexOf(needle);
58
+ while (i !== -1) { n++; i = text.indexOf(needle, i + needle.length); }
59
+ return n;
60
+ };
61
+
62
+ /**
63
+ * findViolations({skillId, agents}) — agents: [{name, role, text}]
64
+ * Returns {ok, violations:[{kind, agent, pattern, count}]}.
65
+ *
66
+ * Only the baseline is judged on reads: the treatment is SUPPOSED to have the skill, and the loader
67
+ * reads both files by design. Every arm is judged on writes.
68
+ */
69
+ export function findViolations({ skillId, agents }) {
70
+ if (!skillId) throw new Error('findViolations: skillId is required');
71
+ const violations = [];
72
+ const skillPath = `skills/${skillId}/SKILL.md`;
73
+ const rubricPath = `skills/${skillId}/evals/cases.yaml`;
74
+
75
+ for (const a of agents || []) {
76
+ const text = String(a.text || '');
77
+ if (a.role === 'baseline') {
78
+ // Graded separately: reading the skill removes the control; reading the rubric is reading the
79
+ // answer key, which also inflates the baseline's own score.
80
+ const skillHits = countOf(text, skillPath);
81
+ if (skillHits > 0) {
82
+ violations.push({ kind: 'baseline-read-skill', agent: a.name, pattern: skillPath, count: skillHits });
83
+ }
84
+ const rubricHits = countOf(text, rubricPath);
85
+ if (rubricHits > 0) {
86
+ violations.push({ kind: 'baseline-read-rubric', agent: a.name, pattern: rubricPath, count: rubricHits });
87
+ }
88
+ }
89
+ if (a.role === 'baseline' || a.role === 'treatment') {
90
+ for (const p of PROTECTED_PATHS) {
91
+ if (!text.includes(p)) continue;
92
+ // Require a write verb near the path, or a mention becomes a violation and the grader's
93
+ // quotations would block every run.
94
+ const wrote = WRITE_SIGNALS.some((verb) => {
95
+ let i = text.indexOf(p);
96
+ while (i !== -1) {
97
+ const window = text.slice(Math.max(0, i - 400), i + 400);
98
+ if (window.includes(verb)) return true;
99
+ i = text.indexOf(p, i + p.length);
100
+ }
101
+ return false;
102
+ });
103
+ if (wrote) {
104
+ violations.push({ kind: 'wrote-protected-path', agent: a.name, pattern: p, count: countOf(text, p) });
105
+ }
106
+ }
107
+ }
108
+ }
109
+ return { ok: violations.length === 0, violations };
110
+ }
111
+
112
+ /** Read a workflow transcript directory into [{name, role, text}]. Throws if unreadable. */
113
+ export function readAgents(dir) {
114
+ if (!dir) throw new Error('readAgents: transcripts dir is required');
115
+ const st = statSync(dir); // throws ENOENT — deliberately not caught here
116
+ if (!st.isDirectory()) throw new Error(`readAgents: not a directory: ${dir}`);
117
+ return readdirSync(dir)
118
+ .filter((f) => /^agent-.*\.jsonl$/.test(f))
119
+ .map((f) => {
120
+ const text = readFileSync(join(dir, f), 'utf8');
121
+ return { name: f, role: classifyAgent(text), text };
122
+ });
123
+ }
124
+
125
+ /**
126
+ * checkIntegrity({skillId, transcriptsDir}) → {ok, blocked, reason, violations, counts}
127
+ *
128
+ * FAILS CLOSED. A missing dir, an unreadable dir, or zero baseline transcripts all return
129
+ * blocked — never a silent pass. "We could not look" must never render as "we looked and it was
130
+ * fine", which is the single failure mode this whole file exists for.
131
+ */
132
+ export function checkIntegrity({ skillId, transcriptsDir }) {
133
+ let agents;
134
+ try {
135
+ agents = readAgents(transcriptsDir);
136
+ } catch (e) {
137
+ return { ok: false, blocked: true, reason: `integrity not verifiable: ${e.message}`, violations: [], counts: {} };
138
+ }
139
+ const counts = agents.reduce((acc, a) => { acc[a.role] = (acc[a.role] || 0) + 1; return acc; }, {});
140
+ if (!counts.baseline) {
141
+ return {
142
+ ok: false, blocked: true,
143
+ reason: `integrity not verifiable: no baseline transcript found in ${transcriptsDir}`,
144
+ violations: [], counts,
145
+ };
146
+ }
147
+ const { ok, violations } = findViolations({ skillId, agents });
148
+ if (ok) return { ok: true, blocked: false, reason: 'integrity verified', violations: [], counts };
149
+ return {
150
+ ok: false, blocked: true,
151
+ reason: `${violations.length} integrity violation(s) — this run measures nothing`,
152
+ violations, counts,
153
+ };
154
+ }
@@ -1,15 +1,25 @@
1
1
  #!/usr/bin/env node
2
2
  // skill-behavior-eval.js — turn the behavior workflow's raw JSON into a scorecard.
3
3
  // Usage:
4
- // node scripts/skill-behavior-eval.js --score <raw.json> (or pipe raw JSON on stdin with -)
4
+ // node scripts/skill-behavior-eval.js --score <raw.json> [--transcripts <dir>]
5
5
  // node scripts/skill-behavior-eval.js --holdout <raw.json> (fresh-scenario transfer gate)
6
6
  // Exits 0 if the gate passes, 1 if it fails/blocks, 2 on usage/parse error.
7
7
  //
8
+ // --transcripts runs the integrity check (scripts/lib/eval-integrity.js) over the workflow's agent
9
+ // transcripts: did the "without the skill" arm read the skill, or its grading rubric, or write into
10
+ // 02-DOCS? Any of those and the verdict is BLOCKED and the numbers are withheld, because a lift
11
+ // measured against a contaminated control is not a measurement.
12
+ //
13
+ // Without --transcripts the scorecard says "integrity NOT CHECKED" on the verdict line. That is
14
+ // deliberate: the flag is optional so existing callers keep working, so silence must not read as
15
+ // clean.
16
+ //
8
17
  // --holdout exists so the fix loop's blocking decision is a comparison of numbers an agent reports,
9
18
  // never a judgement an agent makes (constitution P1).
10
19
 
11
20
  import { readFileSync } from 'node:fs';
12
21
  import { scoreFromRaw, formatScorecard, holdoutGate, formatHoldoutVerdict } from './lib/behavior-score.js';
22
+ import { checkIntegrity } from './lib/eval-integrity.js';
13
23
 
14
24
  function readInput(argPath) {
15
25
  if (argPath && argPath !== '-') return readFileSync(argPath, 'utf8');
@@ -25,12 +35,21 @@ function main() {
25
35
  }
26
36
  let raw;
27
37
  try {
28
- raw = JSON.parse(readInput(args[1]));
38
+ raw = JSON.parse(readInput(args[1] && args[1] !== '--transcripts' ? args[1] : '-'));
29
39
  } catch (e) {
30
40
  process.stderr.write(`parse error: ${e.message}\n`);
31
41
  process.exit(2);
32
42
  }
33
- const scored = scoreFromRaw(raw);
43
+ const ti = args.indexOf('--transcripts');
44
+ const transcriptsDir = ti !== -1 ? args[ti + 1] : null;
45
+ if (ti !== -1 && !transcriptsDir) {
46
+ process.stderr.write('--transcripts needs a directory\n');
47
+ process.exit(2);
48
+ }
49
+ const integrity = transcriptsDir
50
+ ? checkIntegrity({ skillId: (raw && raw.skillId) || '', transcriptsDir })
51
+ : undefined;
52
+ const scored = scoreFromRaw(raw, integrity);
34
53
  if (mode === '--holdout') {
35
54
  const gate = holdoutGate(scored);
36
55
  process.stdout.write(formatHoldoutVerdict(scored, gate) + '\n');
@@ -90,6 +90,10 @@ if (!loaded || !Array.isArray(loaded.scenarios) || loaded.scenarios.length === 0
90
90
  return { skillId, scenarios: [], error: 'no-capability-scenarios' }
91
91
  }
92
92
 
93
+ // A distinct write zone per (scenario, arm). Under .rsc/, which is already gitignored, so an eval
94
+ // write can neither dirty the tracked tree nor show up later as an abandoned diff.
95
+ const sandbox = (index, arm) => `.rsc/eval-sandbox/${skillId}/${arm}-${index}/`
96
+
93
97
  phase('Execute & Grade')
94
98
  const results = await pipeline(
95
99
  loaded.scenarios,
@@ -97,7 +101,18 @@ const results = await pipeline(
97
101
  (sc, _orig, index) => parallel([
98
102
  () => agent(
99
103
  `Complete this task fully and concretely. Produce the ACTUAL deliverable the task asks for, ` +
100
- `not a description of how you would do it.\n\nTASK:\n${sc.scenario}`,
104
+ `not a description of how you would do it.\n\n` +
105
+ // The baseline IS the control. Reading the skill destroys the only thing this arm is for, so
106
+ // the prohibition carries its reason — a rule without one gets rationalised away first. This
107
+ // is guidance, not a barrier: the barrier is the post-hoc check in scripts/lib/eval-integrity.js,
108
+ // which BLOCKS the run if this arm touched the skill or its rubric.
109
+ `CONSTRAINTS — you are the control arm of a measurement:\n` +
110
+ `- Do NOT read anything under skills/ . You are the "without the skill" arm; if you read the ` +
111
+ `skill under test there is no control and the measurement is void.\n` +
112
+ `- Do NOT read any evals/cases.yaml — that is the rubric you are being graded against.\n` +
113
+ `- The repository is READ-ONLY. Never write to 02-DOCS/ or to any tracked file.\n` +
114
+ `- Write every file you produce under ${sandbox(index, 'baseline')} .\n\n` +
115
+ `TASK:\n${sc.scenario}`,
101
116
  { label: `baseline:${index}`, phase: 'Execute & Grade' },
102
117
  ),
103
118
  () => agent(
@@ -105,6 +120,13 @@ const results = await pipeline(
105
120
  `skills/${loaded.skillId}/references/; read them if the skill points you there.\n\n` +
106
121
  `=== SKILL: ${loaded.skillId} ===\n${loaded.skillBody}\n=== END SKILL ===\n\n` +
107
122
  `Complete this task fully and concretely. Produce the ACTUAL deliverable, not a description.\n\n` +
123
+ // Both arms once wrote the same path (02-DOCS/wiki/sdd/specs/magic-link-login.md) and one
124
+ // clobbered the other mid-run, so the grader scored a file that no longer existed. A distinct
125
+ // sandbox per arm makes that collision impossible by construction rather than by luck.
126
+ `CONSTRAINTS:\n` +
127
+ `- The repository is READ-ONLY. Never write to 02-DOCS/ or to any tracked file: this is a ` +
128
+ `measurement, and it must not mutate the project it runs inside.\n` +
129
+ `- Write every file you produce under ${sandbox(index, 'treatment')} .\n\n` +
108
130
  `TASK:\n${sc.scenario}`,
109
131
  { label: `treatment:${index}`, phase: 'Execute & Grade' },
110
132
  ),
@@ -148,6 +148,7 @@ A skill ships only when every box is checked or a miss is consciously justified.
148
148
  - [ ] **Concrete tooling delegated** to the stack skills rather than reinvented.
149
149
  - [ ] **evals present** — `cases.yaml` (≥5 `should_trigger` incl. non-obvious, ≥4 `should_not_trigger` each with a real-sibling `route_to`, ≥1 `capability` with a `must_include` rubric) + an honest `README.md`. `scripts/eval-lint.sh` passes — but it only checks presence and the counts (≥5/≥4/≥1) and that those keys are lists; the `route_to`-points-at-a-real-sibling, non-obvious phrasings, and `must_include` quality are yours to verify here, not the linter's.
150
150
  - [ ] **verify.sh** present iff the skill has a checkable artifact; process skills rely on evals.
151
+ - [ ] **Every `must_include` item discriminates** — answerable by the scenario's task, plausibly caused by the skill and plausibly missed without it. An item *both* arms fail measures nothing and lowers the absolute; it has turned a real PASS into a FAIL here. → `references/eval-authoring.md`.
151
152
  - [ ] **Sibling links resolve** — every `../x/SKILL.md` points to a skill that exists.
152
153
  - [ ] **Wired** — `tags` + `recommends` set, `npm run manifest` re-run, and `npm run validate` / `npm run manifest:check` pass (manifest current, no dangling recommends).
153
154
 
@@ -63,6 +63,40 @@ The `must_include` points are the differentiators — the specific things a *goo
63
63
 
64
64
  For a process skill (no `verify.sh`), the capability scenario is the *primary* rigor — it is how you prove the safety rails actually change behavior. Make it count.
65
65
 
66
+ ## A `must_include` item must discriminate, or it subtracts
67
+
68
+ Measured on 2026-08-18, running the behavioral eval on five skills for the first time. Two items
69
+ added to `testing-py` and `testing-web` came back **unsatisfied in both arms** — treatment and
70
+ baseline alike. They demanded the answer *name* a tool (`mutmut`, Stryker); both arms did the
71
+ behaviour (planted a bug, checked the suite caught it) without naming one. Contributing nothing to
72
+ lift and still counting against the coverage term, they dragged `testing-py` from **PASS (9.0,
73
+ lift +2.2) to FAIL (7.5, lift +1.7)**. The rule they encoded was fine; the item was not.
74
+
75
+ Two tests before an item earns its place:
76
+
77
+ 1. **Answerable by the scenario's task.** If the scenario says "write this test file", then "names
78
+ the mutation tool" is not part of that deliverable and no competent answer will contain it.
79
+ 2. **Discriminating.** The skill must plausibly cause it and a bare agent must plausibly miss it.
80
+ An item both arms satisfy measures the model; one both arms fail measures nothing and lowers the
81
+ absolute.
82
+
83
+ **The symptom, so you can catch it from a scorecard:** an item unsatisfied in *both* arms is
84
+ suspect. Nine times out of ten it is written as a spelling ("mentions X") rather than a behaviour
85
+ ("does not treat coverage as proof the suite detects bugs"). Rewrite it as the behaviour, or delete
86
+ it — do not leave it in as an aspiration, because the aggregate cannot tell an aspiration from a
87
+ failure.
88
+
89
+ The same run showed the other side: the equivalent item in `testing-go`, where the skill gives a
90
+ concrete *procedure* rather than a tool name, discriminated hard and nearly doubled the lift
91
+ (1.5 → 2.7). Prescriptive guidance produces gradeable behaviour; a tool name produces a keyword
92
+ check.
93
+
94
+ **And the scenario itself must be executable where it runs.** `verify`'s scenario asked to verify a
95
+ FastAPI orders feature that exists in no checkout, so both arms correctly answered "there is nothing
96
+ to verify" and several items were unsatisfiable by construction — the absolute measured the scenario,
97
+ not the skill. Make a `capability` scenario self-contained: carry the spec, the diff and the reported
98
+ facts inline rather than assuming repo state.
99
+
66
100
  ## README.md — run it honestly
67
101
 
68
102
  `evals/README.md` documents the two-axis run procedure and is candid about limits:
@@ -42,5 +42,3 @@ capability:
42
42
  - "asserts on the return value / observable behavior, not on mock private internals like _mock_calls"
43
43
  - "mentions a strict posture: --strict-markers and/or --strict-config (or the pytest 9 strict_* aliases)"
44
44
  - "at least one negative/error-path test (e.g. raises on non-200), not only the happy path"
45
- - "Does not present branch coverage as proof the suite detects bugs: names mutation testing (mutmut) as the layer that catches assertion-free tests, and scopes it to the changed module rather than the whole tree."
46
- - "Calibrates mutation by risk rather than prescribing it as a default toll, and says a surviving mutant may be semantically equivalent — to be classified with a reason, never killed with an assertion about non-behaviour."
@@ -50,5 +50,3 @@ capability:
50
50
  - Tests an error path (500 -> visible error / role=alert) in a separate case
51
51
  - No bare act() misuse in the component test; no assertions on state/props/className
52
52
  - At least one real expect(...) assertion per test
53
- - "Does not present coverage as proof the suite detects bugs: names Stryker as the layer that catches assertion-free tests, and scopes `mutate` to the changed files because a whole-project run does not finish."
54
- - "Calibrates mutation by risk rather than prescribing it always, and treats a surviving mutant as possibly equivalent (classified with a reason) instead of adding an assertion about non-behaviour."
@@ -61,13 +61,38 @@ should_not_trigger:
61
61
  # and refuses to call it done without evidence; an ungrounded answer tends to
62
62
  # eyeball the code, trust stale results, or declare success prematurely.
63
63
  capability:
64
- - scenario: "Implementation of the 'duplicate-submit is a no-op' order feature just finished in a FastAPI service. The user says 'it's done, let's merge'. Verify it."
64
+ - scenario: >
65
+ A change is finished and the user says "it's done, let's merge". Verify it, and produce the
66
+ verification record.
67
+
68
+ Everything you need is here — do NOT assume any of it exists on disk; this scenario is
69
+ self-contained on purpose.
70
+
71
+ APPROVED SPEC (acceptance criteria):
72
+ AC1. Submitting an order returns 201 with the created order id.
73
+ AC2. Submitting the SAME order twice creates exactly one order; the second call returns
74
+ 200 with the first order's id.
75
+ AC3. An order with a negative quantity is rejected with 422 and nothing is stored.
76
+
77
+ TASK DONE-CHECKS from the plan:
78
+ T1. POST /orders creates an order. done-check: a test asserts 201 + id.
79
+ T2. Duplicate POST is a no-op. done-check: a test asserts one row after two POSTs.
80
+ T3. Negative quantity is rejected. done-check: a test asserts 422 + no write.
81
+
82
+ WHAT THE IMPLEMENTER REPORTS:
83
+ - Files changed: app/orders.py, tests/test_orders.py
84
+ - "I ran the suite: 2 passed. Tests written: test_create_order_returns_201,
85
+ test_negative_quantity_rejected."
86
+ - "mypy wasn't installed so the type step printed SKIP, and coverage ran without a
87
+ threshold configured. Everything else was green."
88
+ - "The retry loop can spin forever" was raised in review; the implementer replied
89
+ "not a real problem".
65
90
  must_include:
66
91
  - "Reads the accompaniment level from 02-DOCS/wiki/harness/user-profile.md and adapts verbosity (verdict legibility unchanged by the dial)."
67
- - "LOCATES the spec acceptance criteria and the task done-checks, and identifies the FastAPI subproject as the touched stack from git diff/manifests."
68
- - "RUNS the fastapi stack scripts/verify.sh (ruff/black, mypy, pytest+coverage, pip-audit) from the documented root and captures the output verbatim rather than reusing a previous run."
69
- - "Treats any tool SKIP (missing tool) as UNVERIFIED, not as a pass, and notes the constitution's coverage floor as part of the gate."
70
- - "WALKS every task done-check and every acceptance criterion, marking each with concrete evidence (a passing test / observable result) or as an open FAIL item — specifically flags the duplicate-submit criterion as UNVERIFIED if no test exercises the duplicate-POST path."
92
+ - "LOCATES the three acceptance criteria and the three task done-checks from the material given, and maps each to what the implementer actually reports."
93
+ - "Refuses to accept the implementer's summary as the gate: says the checks must be re-run and the output read, rather than trusting a reported '2 passed'."
94
+ - "Treats the mypy SKIP as UNVERIFIED rather than a pass, and does not let 'everything else was green' stand in for the skipped layer."
95
+ - "WALKS all three done-checks and all three acceptance criteria, and catches that AC2/T2 (duplicate POST is a no-op) has NO test at all — the two named tests cover AC1 and AC3 only — marking it UNVERIFIED rather than passing it."
71
96
  - "Does NOT fix a failing test inline; if a check fails it records it and hands off to debug, and refuses to fix-then-claim."
72
97
  - "RECORDS a dated verification file under 02-DOCS/wiki/sdd/verifications/ and indexes it in the root CLAUDE.md Knowledge map."
73
98
  - "Returns a VERDICT that is PASS only if EVERY item has passing evidence; a single unverified acceptance criterion makes the whole verdict FAIL — and refuses to call it 'done' otherwise."