@ericrisco/rsc 1.0.12 → 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.12",
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.12",
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:
@@ -9,9 +9,20 @@
9
9
  # Auto-detects each tool. If a tool is missing it prints a yellow SKIP and continues
10
10
  # (it never FAILs on a missing tool). Prefers `uv run <tool>` when `uv` is present,
11
11
  # else the bare tool on PATH. Exits non-zero only if a tool actually ran and reported
12
- # a failure. The coverage threshold is read from the project's pyproject.toml
13
- # (--cov-fail-under); this script does not hardcode a second threshold. Idempotent:
14
- # re-running yields the same result (read-only beyond whatever the project's pytest does).
12
+ # a failure. Idempotent: re-running yields the same result (read-only beyond whatever
13
+ # the project's pytest does).
14
+ #
15
+ # COVERAGE IS ONLY A GATE IF YOUR PROJECT MAKES IT ONE. The threshold is deliberately
16
+ # delegated to the project (--cov-fail-under in pyproject.toml / setup.cfg / .coveragerc)
17
+ # rather than hardcoded here. But when the project sets none, `pytest --cov` prints a
18
+ # percentage and exits 0 however far coverage falls — and a layer that prints a number and
19
+ # exits 0 is a report, not a gate. That case is reported as [gap] instead of PASS, because
20
+ # the two are indistinguishable on screen and the broken one can only ever produce green:
21
+ # no failing run will ever surface it.
22
+ #
23
+ # A [gap] does NOT change the exit code — breaking every repo that passes today is not this
24
+ # script's call. This script reports; the `verify` skill judges, and there a gap counts as
25
+ # an unverified criterion, which fails the verdict.
15
26
  #
16
27
  # Compatible with stock macOS bash 3.2: no `mapfile`, no associative arrays, and every
17
28
  # array access is guarded so `set -u` never trips on an "unbound" empty array.
@@ -30,7 +41,28 @@ warn() { printf '%s%s%s\n' "$YELLOW" "$*" "$RESET"; }
30
41
  ok() { printf '%s%s%s\n' "$GREEN" "$*" "$RESET"; }
31
42
  fail() { printf '%s%s%s\n' "$RED" "$*" "$RESET"; }
32
43
 
33
- PASSED=0; SKIPPED=0; FAILED=0
44
+ PASSED=0; SKIPPED=0; FAILED=0; GAPS=0
45
+
46
+ # gap <reason> — the layer ran and had nothing to fail with. Counted and printed, but it
47
+ # deliberately does NOT touch FAILED: see the header note on why the exit code is unchanged.
48
+ gap() { printf '%sGAP: %s%s\n' "$YELLOW" "$*" "$RESET"; GAPS=$((GAPS + 1)); }
49
+
50
+ # coverage_threshold_configured — does the project actually gate on coverage?
51
+ # Looks for pytest's --cov-fail-under (addopts or a bare flag) and coverage.py's fail_under.
52
+ # Grep rc: 0 = found, 1 = not found, >=2 = the scan itself broke. Treat "broke" as NOT
53
+ # configured so the answer errs toward reporting a gap; a broken scan must never buy a pass.
54
+ coverage_threshold_configured() {
55
+ local f rc
56
+ for f in "$TARGET/pyproject.toml" "$TARGET/setup.cfg" "$TARGET/tox.ini" "$TARGET/.coveragerc" "$TARGET/pytest.ini"; do
57
+ [ -f "$f" ] || continue
58
+ grep -qE 'cov-fail-under|fail_under' "$f" && return 0
59
+ rc=$?
60
+ if [ "$rc" -ge 2 ]; then
61
+ warn "NOTE: could not read '${f}' while checking for a coverage threshold; treating it as absent"
62
+ fi
63
+ done
64
+ return 1
65
+ }
34
66
 
35
67
  have() { command -v "$1" >/dev/null 2>&1; }
36
68
 
@@ -105,12 +137,21 @@ run_step "ruff check" ruff check "$TARGET"
105
137
  run_step "ruff format check" ruff format --check "$TARGET"
106
138
  run_step "mypy" mypy "$TARGET"
107
139
  run_step "pytest + coverage" pytest --cov --cov-report=term-missing
140
+ # Only meaningful if pytest actually ran; a SKIP already says nothing was measured.
141
+ if tool_available pytest && ! coverage_threshold_configured; then
142
+ gap "coverage ran without a threshold (no --cov-fail-under / fail_under found) — this layer cannot fail. Add --cov-fail-under=<n> to pyproject.toml addopts to make it a gate."
143
+ fi
108
144
  run_step "pip-audit" pip-audit
109
145
 
110
- printf '\n%d passed, %d skipped, %d failed\n' "$PASSED" "$SKIPPED" "$FAILED"
146
+ printf '\n%d passed, %d skipped, %d failed, %d gap(s)\n' "$PASSED" "$SKIPPED" "$FAILED" "$GAPS"
147
+ if [ "$GAPS" -gt 0 ]; then
148
+ warn "A GAP is neither a pass nor a failure: the layer ran and could not have failed. Report it as unverified."
149
+ fi
111
150
  if [ "$FAILED" -gt 0 ]; then
112
151
  fail "verify.sh: failures detected"
113
152
  exit 1
114
153
  fi
115
- ok "verify.sh: ok"
154
+ # "every check that could fail, passed" rather than a bare ok: with a GAP present, the
155
+ # stronger claim would be false in exactly the way this script now exists to prevent.
156
+ ok "verify.sh: ok (every check that could fail, passed)"
116
157
  exit 0
@@ -11,6 +11,17 @@
11
11
  # Real problems (unformatted code, vet/test/vuln failures) exit non-zero.
12
12
  # Read-only: never mutates your source.
13
13
  #
14
+ # ONE LAYER HERE IS A REPORT, NOT A GATE. `go test -cover` takes no threshold flag, so
15
+ # coverage can print any number — 4%, 0% — and still exit 0. It is therefore reported as
16
+ # [gap]: the layer ran and had nothing to fail with. Said out loud because a layer that
17
+ # prints a number and exits 0 looks exactly like a layer that passed, and that shape can
18
+ # only ever produce green — no failing run will ever tell you about it. Want a real
19
+ # coverage gate in Go? Parse `go tool cover -func` and compare, or use a CI action; this
20
+ # script does not pretend to be one.
21
+ #
22
+ # A [gap] does NOT change the exit code: this script reports, the `verify` skill judges,
23
+ # and there it counts as an unverified criterion, which fails the verdict.
24
+ #
14
25
  # Portability: runs on stock macOS bash 3.2 (no mapfile, no associative arrays,
15
26
  # no unguarded array expansions under `set -u`).
16
27
 
@@ -24,12 +35,16 @@ else
24
35
  fi
25
36
 
26
37
  failed=0
38
+ gaps=0
27
39
 
28
40
  have() { command -v "$1" >/dev/null 2>&1; }
29
41
  warn() { printf '%s[skip]%s %s\n' "$YELLOW" "$RESET" "$*"; }
30
42
  fail() { printf '%s[fail]%s %s\n' "$RED" "$RESET" "$*"; failed=1; }
31
43
  ok() { printf '%s[ ok ]%s %s\n' "$GREEN" "$RESET" "$*"; }
32
44
  info() { printf -- '----- %s\n' "$*"; }
45
+ # A layer that ran but had nothing to fail with. Deliberately does NOT set `failed`:
46
+ # breaking every repo that passes today is not this script's call to make.
47
+ gap() { printf '%s[gap ]%s %s\n' "$YELLOW" "$RESET" "$*"; gaps=$((gaps + 1)); }
33
48
 
34
49
  # Must run from a module root.
35
50
  if [ ! -f go.mod ]; then
@@ -89,6 +104,10 @@ if have go; then
89
104
  warn "cgo disabled or C compiler '$cgo_cc' not found; running plain go test -cover"
90
105
  if go test -cover ./...; then ok "tests pass (no race)"; else fail "tests failed"; fi
91
106
  fi
107
+ # The tests above are a gate; the coverage number printed alongside them is not.
108
+ # `go test -cover` has no threshold flag, so coverage cannot fail this script no matter
109
+ # how far it falls. Reported so nobody reads the percentage as a checked constraint.
110
+ gap "coverage: reported, not gated — 'go test -cover' takes no threshold, so this layer cannot fail"
92
111
  else
93
112
  warn "go not found - skipping tests"
94
113
  fi
@@ -102,8 +121,14 @@ else
102
121
  fi
103
122
 
104
123
  echo
124
+ if [ "$gaps" -ne 0 ]; then
125
+ printf '%s%d layer(s) ran without being able to fail (see [gap] above).%s\n' "$YELLOW" "$gaps" "$RESET"
126
+ printf 'Not a pass and not a failure: report them as unverified rather than green.\n'
127
+ fi
105
128
  if [ "$failed" -ne 0 ]; then
106
129
  printf '%sFAIL:%s one or more checks failed.\n' "$RED" "$RESET"
107
130
  exit 1
108
131
  fi
109
- printf '%sPASS:%s all checks passed.\n' "$GREEN" "$RESET"
132
+ # Deliberately "checks that could fail, passed" and not "all checks passed": with a [gap]
133
+ # present the second sentence would be false in the way this whole change exists to stop.
134
+ printf '%sPASS:%s every check that could fail, passed.\n' "$GREEN" "$RESET"
@@ -76,6 +76,24 @@ code tells you nothing about whether it can fail. **Refactor only on green** kee
76
76
  reversible against a passing bar. **Checkpoint after each task** is what makes this resumable and
77
77
  reviewable instead of a 2000-line surprise diff.
78
78
 
79
+ ### Four things you may never do to reach green
80
+
81
+ The loop only means anything if green cannot be manufactured. These are absolute, and each one is a
82
+ way people reach green without fixing anything:
83
+
84
+ 1. **Never weaken a test to make it pass.** Not broadening an assertion, not adding a skip, not
85
+ raising a tolerance, not deleting it. A test that looks wrong is a **spec conversation** — surface
86
+ it, don't bury it.
87
+ 2. **Never edit a test and the implementation in the same step to reach green.** Change one, run,
88
+ then the other. Simultaneous edits let you redefine correctness to match your bug without
89
+ noticing you did it.
90
+ 3. **Never mock the unit under test**, or mock so much that the test only exercises the mocks. Mock
91
+ boundaries you don't own — network, clock, filesystem, third-party SDK — never the logic you are
92
+ being paid to verify.
93
+ 4. **Never chase the coverage number.** Coverage detects untested code; it is not a target. A test
94
+ added to touch lines, with no meaningful assertion, is gaming — and mutation testing exists
95
+ precisely to catch it (`../testing-py/SKILL.md`, `../testing-web/SKILL.md`, `../testing-go/SKILL.md`).
96
+
79
97
  ### The done-check is the contract
80
98
 
81
99
  Each task carries a done-check from the `tasks` phase ("returns 409 on duplicate email", "list
@@ -128,9 +128,41 @@ gap deliberately with `--accept-partial-lenses` (it is recorded). Every approval
128
128
 
129
129
  **Lenses by risk tier** — tier 0 never reaches you (the gate passes docs/copy silently);
130
130
  tier 1 → run the single most relevant pass from the table above yourself; tier 2 → dispatch
131
- **three parallel fresh-context subagents** (correctness · security · tests-as-evidence), each
132
- given only the diff and told to *refute* readiness, not confirm it. Fresh context is the point:
133
- a reviewer who inherits the implementer's context inherits its blind spots.
131
+ **three parallel fresh-context subagents** (correctness · security · tests-as-evidence), each told
132
+ to *refute* readiness, not confirm it. Fresh context is the point: a reviewer who inherits the
133
+ implementer's context inherits its blind spots.
134
+
135
+ **Give each refuter exactly four inputs — no more, no less.** The diff alone is not enough, and
136
+ that is not a detail: with only the diff, the one failure class no test suite can structurally
137
+ catch — *the change is correct and is not what the approved spec asked for* — is invisible to the
138
+ whole panel.
139
+
140
+ 1. **The task contract** — the original request **plus every scope change a human explicitly
141
+ approved since.** Not just the first message: without the approved changes a legitimate scope
142
+ revision reads as a spec gap and you get a confident false positive.
143
+ 2. **The approved spec** (`02-DOCS/wiki/sdd/specs/<slug>.md`).
144
+ 3. **The exact source state** — commit SHA, or a tree hash when git is absent. A verdict belongs to
145
+ the state that was reviewed, not to the project.
146
+ 4. **The entry point** — the one command that reruns the checks.
147
+
148
+ **And say what they do not get:** your conversation, your reasoning, your defences, and your draft
149
+ verdict. If a claim needs your justification to stand, it is not proven.
150
+
151
+ **Blind first, compare second.** Each refuter records what it attacked and what it found *before*
152
+ being shown your conclusions. Only then may it compare and add findings — the blind record is
153
+ append-only after that, never rewritten. Skip this and the fresh context is spent confirming your
154
+ framing, which is the one thing it was bought to avoid.
155
+
156
+ **The attack list is the deliverable, not just the findings.** "Nothing found" without saying where
157
+ you looked is indistinguishable from not having looked — the same rule that governs a dismissal.
158
+
159
+ Two extra finding classes the four inputs unlock, neither reachable from a diff:
160
+
161
+ - **Contract vs spec** — what would a caller reasonably expect, given the stated deployment, that
162
+ no clause covers? An exclusion the spec deliberately approved is **not** a finding; an exclusion
163
+ the spec describes *inaccurately* is.
164
+ - **Mapping, both directions** — an acceptance criterion with no test that can be made to fail, and
165
+ a test pinning behaviour no criterion asked for.
134
166
 
135
167
  **A finding blocks only if it survives all three filters** — no exceptions, and eagerness to
136
168
  find something is not evidence:
@@ -78,3 +78,5 @@ capability:
78
78
  - "Captures observable behaviour (what the user sees/can do) and explicit non-goals / out-of-scope rather than implementation (no file format library, storage, or job-queue detail)."
79
79
  - "Turns answers into binary acceptance criteria and defers unresolved items (e.g. data scope, retention, format) to Points to clarify instead of fabricating decisions."
80
80
  - "Notes that the spec is complete-enough-to-plan with open points remaining, and hands off to clarify — not to plan or implementation."
81
+ - "After the user answers a question, folds the answer in, states what changed, shows the revised spec, and asks for approval AGAIN — it does not treat the answer as approval of the spec, including when the user picked the recommended option."
82
+ - "If told to run the whole chain up front, records the approval as autopilot rather than item-by-item, and says the spec is the artifact to review after the fact."
@@ -42,3 +42,5 @@ capability:
42
42
  - A hand-written interface fake or net/http/httptest server for the HTTP dependency, not a mock framework by default
43
43
  - Runs with -race for the concurrent path and shows a go test -coverprofile / cross-package coverage command
44
44
  - A benchmark using for b.Loop() with b.ReportAllocs() rather than a b.N loop
45
+ - "Does not present the coverage percentage as proof the suite detects bugs: states Go has no mature mutation tool, gives the manual procedure, and distinguishes mutation (tests that would not notice a bug) from fuzzing (inputs the code mishandles)."
46
+ - "Requires a hand-rolled mutant runner to prove it executed each mutant, and says why — a kill reported without running can only inflate the score, so no failing run would ever reveal it."
@@ -80,6 +80,7 @@ If `config.yaml` provides `testing.commands.verify`, run those commands first or
80
80
  Rules for RUN:
81
81
 
82
82
  - The stack `verify.sh` **skips missing tools** (yellow SKIP) rather than failing on them — so a SKIP is not a pass. Note every SKIP; a skipped test suite means that criterion is **unverified**, which is not the same as verified-passing.
83
+ - A `GAP` is the third thing, and it is the one that looks most like a pass: **the layer ran and had nothing to fail with.** The fastapi gate reports it when coverage runs with no `--cov-fail-under` configured; the Go gate reports it always, because `go test -cover` takes no threshold. The script deliberately does not fail on a GAP — that judgement is yours. **Treat a GAP as an unverified criterion, which fails the verdict**, and record it (see the mapping below). A gate that reports and a gate that checks are indistinguishable on screen, and only the broken one is guaranteed to stay green.
83
84
  - A non-zero exit from a tool that actually ran is a hard FAIL. Record the failing tool and its output.
84
85
  - Run from the directory the stack skill documents (Go runs from the module root; others from the subproject root). Don't guess paths.
85
86
  - If no `scripts/verify.sh` exists for a touched stack, that's a gap — say the gate is incomplete and recommend the user add the stack skill, rather than hand-rolling a one-off check that drifts from the real gate.
@@ -157,6 +158,15 @@ source_state: 3f9a1c2 (clean)
157
158
 
158
159
  `SUSTITUIDA` is the dangerous one. "Ran the suite twice" is not *suite health: stable* — it is a substitute that cannot see whole-suite order dependence, and a reader who can't tell the two apart reads "found nothing" where the truth is "did not look with that instrument".
159
160
 
161
+ **Where a `GAP` goes — and do not invent a fourth label.** A layer that ran without being able to fail is not `NO-APLICA` (the surface exists) and not `HERRAMIENTA-AUSENTE` (the tool ran). It is `SUSTITUIDA`: what ran was **a report in place of a gate**, and what it cannot detect is **coverage falling**. Write it that way:
162
+
163
+ ```text
164
+ - **SUSTITUIDA** — coverage: ran without a threshold (GAP). A report, not a gate;
165
+ cannot detect coverage regressing. Fix: --cov-fail-under=<n> in pyproject addopts.
166
+ ```
167
+
168
+ **And while you are there: the global percentage is the wrong number.** A project-wide floor barely moves when your change lands untested — 200 new uncovered lines in a 20k-line codebase is a rounding error against an 85% floor, so the gate passes and the change is untested. What matters is whether **the lines you touched** are exercised: `diff-cover coverage.xml --fail-under=100` gates exactly those. This is guidance, not something the generated `verify.sh` scripts do — recommend it, don't assume it ran.
169
+
160
170
  **A dismissed finding carries evidence, one line each.** A fix is self-evidencing — the test now passes. A dismissal is not: "not a real problem" is indistinguishable from "did not check". Name the command, the `file:line`, or the test that disproves it, or write "ninguno".
161
171
 
162
172
  Append-only spirit: don't overwrite a prior run's record; a new run is a new dated file. The record is the receipt the `review` and `ship` phases trust.
@@ -61,14 +61,44 @@ 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."
74
99
  - "On PASS, points to the review phase; on FAIL, routes failing checks to debug and missing coverage back to implement."
100
+ - "Treats a coverage GAP (the layer ran with no threshold configured, so it could not have failed) as an unverified criterion that fails the verdict — not as a pass — and records it as SUSTITUIDA: a report in place of a gate, unable to detect coverage regressing."
101
+ - "Before trusting any home-grown gate that reports green, asks for it to be watched failing on a known-bad input, and states the limit: one negative control proves one bad case reaches the failure path, not that the gate covers the rule it claims to enforce."
102
+ - "Splits layers that did not run into NO-APLICA / HERRAMIENTA-AUSENTE / SUSTITUIDA rather than one undifferentiated SKIP list, and never writes SUSTITUIDA as a pass."
103
+ - "Binds the record to a source state (short commit SHA plus clean/dirty) and takes every number from one fresh run made after the last edit, refusing to reuse mid-task figures."
104
+ - "Gives any dismissed finding its own evidence line (command, file:line, or the test that disproves it) rather than asserting it was not a real problem."