@event4u/agent-config 10.2.0 → 10.4.0

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.
Files changed (96) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/CHANGELOG.md +168 -256
  3. package/README.md +1 -1
  4. package/dist/agent-src/commands/analyze/reference-repo.md +62 -0
  5. package/dist/agent-src/commands/analyze.md +2 -1
  6. package/dist/agent-src/commands/design-system/capture.md +44 -0
  7. package/dist/agent-src/commands/design-system/generate.md +53 -0
  8. package/dist/agent-src/commands/design-system/import.md +63 -0
  9. package/dist/agent-src/commands/design-system.md +66 -0
  10. package/dist/agent-src/commands/roadmap/create.md +4 -1
  11. package/dist/agent-src/personas/advisors/first-principles.md +6 -0
  12. package/dist/agent-src/rules/active-remediation.md +53 -2
  13. package/dist/agent-src/rules/code-provenance.md +66 -4
  14. package/dist/agent-src/rules/content-quoting-floor.md +6 -0
  15. package/dist/agent-src/rules/design-fidelity.md +49 -3
  16. package/dist/agent-src/skills/design-review/references/verification-automation.md +23 -1
  17. package/dist/agent-src/skills/design-system-capture/SKILL.md +9 -0
  18. package/dist/agent-src/skills/design-system-capture/references/design-system-json.md +70 -0
  19. package/dist/agent-src/skills/fe-design/SKILL.md +27 -3
  20. package/dist/agent-src/skills/judge-synthesis/SKILL.md +30 -0
  21. package/dist/agent-src/skills/learning-to-rule-or-skill/SKILL.md +11 -0
  22. package/dist/agent-src/skills/roadmap-writing/SKILL.md +8 -8
  23. package/dist/agent-src/skills/skill-writing/SKILL.md +4 -0
  24. package/dist/agent-src/templates/agents/agent-project-settings.example.yml +1 -1
  25. package/dist/agent-src/templates/persona.md +26 -0
  26. package/dist/agent-src/templates/roadmaps.md +27 -7
  27. package/dist/agent-src/templates/scripts/work_engine/directives/ui/design.ts +4 -1
  28. package/dist/agent-src/templates/skill.md +35 -1
  29. package/dist/cli/commands/commands.candidates.test.js +257 -0
  30. package/dist/cli/commands/commands.candidates.test.js.map +1 -0
  31. package/dist/cli/commands/commands.js +199 -0
  32. package/dist/cli/commands/commands.js.map +1 -1
  33. package/dist/cli/main.js +1 -0
  34. package/dist/cli/main.js.map +1 -1
  35. package/dist/cli/registry.js +1 -1
  36. package/dist/cli/registry.js.map +1 -1
  37. package/dist/cli-delegate/{chunk-E4RUZKXB.js → chunk-FV34EGEX.js} +1 -1
  38. package/dist/cli-delegate/chunk-RLN73PR5.js +157 -0
  39. package/dist/cli-delegate/{chunk-I5EAFRII.js → chunk-ZPSXJTLI.js} +2 -154
  40. package/dist/cli-delegate/cmd_conformance.js +672 -214
  41. package/dist/cli-delegate/cmd_doctor.js +1 -1
  42. package/dist/cli-delegate/cmd_handoff.js +8 -6
  43. package/dist/cli-delegate/cmd_session_recycle.js +5 -3
  44. package/dist/discovery/deprecation-report.md +1 -1
  45. package/dist/discovery/discovery-manifest.json +127 -22
  46. package/dist/discovery/discovery-manifest.json.sha256 +1 -1
  47. package/dist/discovery/discovery-manifest.summary.md +3 -3
  48. package/dist/discovery/dormancy-report.md +1 -1
  49. package/dist/discovery/orphan-report.md +1 -1
  50. package/dist/discovery/packs.json +9 -5
  51. package/dist/discovery/trust-report.md +2 -2
  52. package/dist/discovery/workspaces.json +8 -4
  53. package/dist/hooks/dispatch.js +1056 -411
  54. package/dist/mcp/registry-manifest.json +3 -3
  55. package/dist/mcp/server.json +2 -2
  56. package/dist/mcp/server.mjs +1103 -593
  57. package/dist/router.json +1 -1
  58. package/docs/guidelines/agent-infra/active-remediation-mechanics.md +54 -0
  59. package/docs/guidelines/design-antipatterns.md +79 -10
  60. package/docs/guidelines/design-fidelity-mechanics.md +120 -2
  61. package/package.json +1 -1
  62. package/src/agent-src/templates/scripts/work_engine/directives/ui/design.ts +4 -1
  63. package/src/config/gate-coverage.yml +65 -10
  64. package/src/config/gate-violation-baselines.json +6 -0
  65. package/src/config/review-prompt-binding-baseline.json +19 -0
  66. package/src/config/rule-enforcement-baseline.json +0 -1
  67. package/src/scripts/_dispatch.bash +1 -0
  68. package/src/scripts/_lib/design_system_import.ts +987 -0
  69. package/src/scripts/ai_council/clients.ts +15 -1
  70. package/src/scripts/check_ci_local_parity.ts +60 -6
  71. package/src/scripts/check_review_prompt_binding.ts +553 -0
  72. package/src/scripts/check_suppression_hygiene.ts +19 -3
  73. package/src/scripts/design_slop_rules.test.ts +37 -6
  74. package/src/scripts/design_slop_rules.ts +150 -22
  75. package/src/scripts/design_system_import.ts +264 -0
  76. package/src/scripts/dispatch_r2_reviewer.ts +15 -1
  77. package/src/scripts/hook_manifest.yaml +71 -2
  78. package/src/scripts/hooks/concern_registry.ts +4 -0
  79. package/src/scripts/hooks/dispatch_hook.ts +10 -0
  80. package/src/scripts/hooks/host_semantics.ts +2 -0
  81. package/src/scripts/hooks/spawn_guard_shadow_hook.ts +212 -0
  82. package/src/scripts/hooks/subagent_ledger_hook.ts +723 -0
  83. package/src/scripts/hooks/turn_end_gate_hook.ts +39 -2
  84. package/src/scripts/hooks/ui_route_nudge_hook.ts +55 -2
  85. package/src/scripts/lint_design_antipattern_parity.ts +201 -0
  86. package/src/scripts/lint_harvest_provenance.ts +483 -0
  87. package/src/scripts/lint_hook_manifest.ts +2 -0
  88. package/src/scripts/lint_rule_skill_pack_reach.ts +194 -13
  89. package/src/scripts/lint_skill_router_head.ts +254 -0
  90. package/src/scripts/report_consultation_rate.ts +126 -1
  91. package/src/scripts/run_skill_evals.ts +10 -5
  92. package/src/scripts/schemas/persona.schema.json +8 -0
  93. package/src/scripts/schemas/provenance-harvest.schema.json +39 -0
  94. package/src/scripts/sweep_dead_scan_roots.ts +51 -0
  95. package/tests/fixtures/hooks/subagent_start.json +7 -0
  96. package/tests/fixtures/hooks/subagent_stop.json +8 -0
@@ -949,7 +949,21 @@ export const CLI_CALLS_FILENAME = 'cli-calls.json';
949
949
  // Default subprocess timeout (seconds) for a single CLI call. Long enough for
950
950
  // the largest frontier models to think; short enough to surface a hung
951
951
  // subprocess without freezing the council run.
952
- export const DEFAULT_CLI_TIMEOUT_SECONDS = 120.0;
952
+ //
953
+ // Raised 120 → 300 (2026-08-13) to match the API transport, which has carried
954
+ // `--max-time 290` / `timeout: 300_000` since the 2026-06-24 repair of the same
955
+ // symptom. That repair landed in `_curlJsonPost` only, so when a member resolves
956
+ // to `cli · subscription` the old cap was still live — and a deep design run
957
+ // reproduced it exactly: both members returned `error: timeout` at
958
+ // `latency_ms: 122921` with `timeout_seconds: 120`, and the run reported
959
+ // `0/2 present — INCONCLUSIVE`.
960
+ //
961
+ // This is not a comfort setting. A council that cannot finish a deep prompt is
962
+ // a council the agent cannot use, and every question it would have answered
963
+ // falls back to interrupting the user — the routing that
964
+ // `decision_resolution` classes `medium_impact → council` depends on this
965
+ // call completing.
966
+ export const DEFAULT_CLI_TIMEOUT_SECONDS = 300.0;
953
967
 
954
968
  /** Return the canonical write target for the daily-quota counter. */
955
969
  function _cliCallsStatePath(): string {
@@ -30,6 +30,8 @@ import { fileURLToPath, pathToFileURL } from 'node:url';
30
30
 
31
31
  import { parse as parseYaml } from 'yaml';
32
32
 
33
+ import { checkRatchet } from './_lib/gate_baseline.js';
34
+
33
35
  const _FILE = fileURLToPath(import.meta.url);
34
36
  export const REPO = path.resolve(path.dirname(_FILE), '..', '..');
35
37
  const MANIFEST = path.join(REPO, 'src', 'config', 'ci-local-parity.yml');
@@ -202,13 +204,49 @@ export function local_closure(roots: readonly string[], tasks: Map<string, TaskD
202
204
  return gates;
203
205
  }
204
206
 
207
+ /**
208
+ * Drop YAML comments before anything is extracted from a workflow.
209
+ *
210
+ * WHY, and it is the defect this checker was blind to for its whole life. The CI
211
+ * side is built by regexing workflow TEXT for `task <name>` and expanding each
212
+ * name's closure. Several workflow comments contain the literal string
213
+ * `task ci` — while stating that **no workflow invokes it**:
214
+ *
215
+ * # invokes `task ci`, so a gate registered only there never runs remotely.
216
+ * # …NO workflow invokes `task ci`, `ci-strict`, or …
217
+ *
218
+ * Read as an invocation, `ci` expanded to its full closure and put all 247
219
+ * `task ci` gates into the "runs in CI" set. `undeclared_local_only` was
220
+ * therefore **0 by construction** — the one direction this manifest's own header
221
+ * calls the one that let real defects merge could not be reported at all, and the
222
+ * prose documenting the gap was what suppressed it.
223
+ *
224
+ * Measured at the repair: CI-side 273 → 106, `undeclared_local_only` 0 → 167 of
225
+ * 247 local gates. A checker whose extraction invents coverage is worse than
226
+ * none, which this file already says about a different extraction bug ten lines
227
+ * up — the same lesson, learned twice.
228
+ *
229
+ * Deliberately crude: a `#` inside a quoted YAML scalar is treated as a comment
230
+ * start. That direction is safe here — it can only make the checker see LESS
231
+ * wiring, i.e. over-report drift, never invent coverage. A YAML parse would be
232
+ * exact, but every workflow would have to be structurally valid for the checker
233
+ * to run at all, and a parity checker that cannot run on a malformed workflow is
234
+ * a parity checker that goes quiet exactly when someone is editing CI.
235
+ */
236
+ export function strip_yaml_comments(text: string): string {
237
+ return text
238
+ .split('\n')
239
+ .map((l) => l.replace(/(^|\s)#.*$/, '$1'))
240
+ .join('\n');
241
+ }
242
+
205
243
  function _read_all(dir: string): string {
206
244
  if (!fs.existsSync(dir)) return '';
207
245
  return fs
208
246
  .readdirSync(dir)
209
247
  .filter((f) => f.endsWith('.yml') || f.endsWith('.yaml'))
210
248
  .sort()
211
- .map((f) => fs.readFileSync(path.join(dir, f), 'utf-8'))
249
+ .map((f) => strip_yaml_comments(fs.readFileSync(path.join(dir, f), 'utf-8')))
212
250
  .join('\n');
213
251
  }
214
252
 
@@ -332,17 +370,33 @@ export function main(argv: readonly string[] = process.argv.slice(2)): number {
332
370
  ' Undeclared, it means a contributor discovers this failure only after pushing.\n',
333
371
  );
334
372
  }
335
- if (r.undeclared_local_only.length > 0) {
373
+ // Ratcheted, not listed one by one.
374
+ //
375
+ // Repairing the comment-blindness above took this count from 0 to 167 in one
376
+ // step — pre-existing debt the checker could not see, not new breakage. A hard
377
+ // fail on 167 findings nobody can clear in the change that reveals them is the
378
+ // gate that lands as N instant blockers, which this repository has recorded as
379
+ // a failure mode and refused before. A shrink-only count keeps the property
380
+ // that matters: the number may not RISE, so a gate added to `task ci` with no
381
+ // workflow reds immediately, which is the regression this direction exists to
382
+ // catch. Listing all 167 every run would bury that signal in its own noise.
383
+ const parity = checkRatchet({
384
+ gate: 'ci-parity:local-only',
385
+ actual: r.undeclared_local_only.length,
386
+ repoRoot: REPO,
387
+ });
388
+ if (!parity.ok) {
336
389
  failed = true;
337
- process.stdout.write(
338
- `❌ ${String(r.undeclared_local_only.length)} gate(s) run locally but in no workflow:\n`,
339
- );
390
+ process.stdout.write(`❌ ${parity.message}\n`);
340
391
  for (const g of r.undeclared_local_only) process.stdout.write(` - ${g}\n`);
341
392
  process.stdout.write(
342
393
  '\n Prefer adding it to a workflow — this is the direction that let real defects\n' +
343
394
  ' merge (16 stale index rows reached main behind a local-only check-index).\n' +
344
- ' If remote enforcement is genuinely not wanted, declare it under `local_only:`.\n',
395
+ ' If remote enforcement is genuinely not wanted, declare it under `local_only:`.\n' +
396
+ ' Raising the baseline is a defect, not a fix.\n',
345
397
  );
398
+ } else {
399
+ process.stdout.write(`ℹ️ ${parity.message}\n`);
346
400
  }
347
401
  if (r.stale_declarations.length > 0) {
348
402
  failed = true;
@@ -0,0 +1,553 @@
1
+ #!/usr/bin/env tsx
2
+ /**
3
+ * The prompt→verdict binding, checked.
4
+ *
5
+ * WHY THIS EXISTS. `docs/contracts/plan-review-gates.md` § 5 recorded the prompt
6
+ * channel as an accepted risk on the premise that *"no in-repo check can read the
7
+ * prompt, so there is no detection floor"*. That premise was true about the HASH
8
+ * and false about the TEXT sitting beside it: `dispatch_r2_reviewer` writes the
9
+ * prompt it built verbatim to `<slug>.review-input/prompt.md`, and those files are
10
+ * tracked. An in-repo check could read the prompt all along; nothing did.
11
+ *
12
+ * `prompt_hash` was written into every findings marker, parsed by
13
+ * `check_completion_review`, and **compared to nothing** — `--verify` /
14
+ * `--verify-current` re-derive `scope_hash`, `roadmap_hash` and `ac_hash` only.
15
+ * A retrospective scan of the committed corpus found the binding already broken
16
+ * in two records, one of them dated after the only known cause was fixed. The
17
+ * only link between a verdict and the prompt that produced it was broken,
18
+ * currently, and nobody noticed — because nothing looked.
19
+ *
20
+ * WHAT IT DOES NOT DO, stated so the gate is not oversold. Three limits, and the
21
+ * first is the cheapest bypass rather than the cleverest:
22
+ *
23
+ * 1. **Omission beats substitution.** Simply not committing
24
+ * `<slug>.review-input/prompt.md` drops the round out of the checkable set
25
+ * with no finding and no signal, because an artefact without a package is
26
+ * deliberately out of scope (see {@link collectPackages}). Nothing in the tree
27
+ * requires the package to exist. Measured on the corpus this gate shipped
28
+ * against: **11 of 19** artefacts already carry a `prompt_hash` with no
29
+ * package, so the bypassed state is the historical norm and is
30
+ * indistinguishable from it. Closing this needs a requirement that packages be
31
+ * committed — a migration decision, not a check.
32
+ * 2. **Substitution stays undetectable.** The host that authors a steered prompt
33
+ * also writes the file, so swapping a clean prompt for the steered one passes.
34
+ * What changes is that steering then requires an act of substitution leaving
35
+ * its own artefact in the commit, instead of being invisible by default.
36
+ * 3. **The steering predicate is one-of-four.** Against the four clauses the
37
+ * case-zero incident records verbatim, `preloadedVerdict` matches exactly one.
38
+ * That is the detection floor this buys, not a solved problem.
39
+ *
40
+ * Corpus-wide on purpose. `check_completion_review` deliberately reports grammar
41
+ * violations only for the branch's own or scope-relevant artefacts, because a
42
+ * stale foreign artefact must not poison an unrelated PR. That reasoning does not
43
+ * transfer here: a committed `prompt.md` and its recorded hash are both immutable,
44
+ * so the binding is branch-independent and a break in any record is a permanent
45
+ * defect. With the two known breaks baselined, an unrelated PR stays green.
46
+ *
47
+ * Exit codes:
48
+ * 0 every checkable binding re-derives, no steered prompt, baseline accurate
49
+ * 1 a binding broke, a prompt carries a pre-loaded verdict, or the baseline
50
+ * no longer describes the corpus
51
+ * 2 internal error (unreadable corpus, malformed baseline)
52
+ */
53
+ import * as crypto from 'node:crypto';
54
+ import * as fs from 'node:fs';
55
+ import * as os from 'node:os';
56
+ import * as path from 'node:path';
57
+ import { fileURLToPath, pathToFileURL } from 'node:url';
58
+
59
+ import { GateLedger } from './_lib/gate_ledger.js';
60
+ import { runGateCli, runSelfTest, type SelfTestCase } from './_lib/gate_self_test.js';
61
+ import { DeadScopeError, reportScanned } from './_lib/scan_scope.js';
62
+ import { parseArtifact } from './check_completion_review.js';
63
+ import { preloadedVerdict } from './hooks/evidence_independence.js';
64
+
65
+ const _HERE = path.resolve(fileURLToPath(import.meta.url));
66
+ const REPO_ROOT = path.resolve(path.dirname(_HERE), '..', '..');
67
+ const DEFAULT_REVIEWS_DIR = 'agents/evidence/reviews';
68
+ const DEFAULT_BASELINE = 'src/config/review-prompt-binding-baseline.json';
69
+
70
+ /** A committed prompt whose recorded hash is known not to re-derive. */
71
+ export interface BaselineEntry {
72
+ slug: string;
73
+ /** The hash the findings marker records. */
74
+ declared: string;
75
+ /** The hash the committed `prompt.md` actually produces. */
76
+ actual: string;
77
+ reason: string;
78
+ /**
79
+ * An acknowledged steering match, pinned to the exact phrase.
80
+ *
81
+ * WHY THIS EXISTS, and why it is separate from the hash exemption. The
82
+ * steering predicate is `preloadedVerdict`, a live regex list owned by
83
+ * `evidence_independence` and expected to grow — its own history includes a
84
+ * phrase dropped for false positives. A committed prompt cannot be edited
85
+ * into compliance without rewriting a historical record. So with no
86
+ * acknowledgement path at all, ONE phrase added to that shared list would
87
+ * convert already-committed prompts into permanent blocks on every unrelated
88
+ * PR, and a gate whose only output is an unfixable block is the gate that
89
+ * gets switched off.
90
+ *
91
+ * The phrase is pinned rather than the slug: acknowledging "this record
92
+ * matches THIS clause, and here is the human read of it" cannot silently
93
+ * cover a DIFFERENT clause matching the same file later. The corpus carries
94
+ * zero of these today, which is the state to keep.
95
+ */
96
+ steeredAck?: { phrase: string; reason: string };
97
+ }
98
+
99
+ export interface Finding {
100
+ kind: 'binding-broken' | 'steered-prompt' | 'stale-baseline';
101
+ slug: string;
102
+ detail: string;
103
+ }
104
+
105
+ export interface Tally {
106
+ /** Artefacts carrying both a `prompt_hash` and a committed prompt package. */
107
+ packages: number;
108
+ /** Packages whose hash re-derives. */
109
+ binding: number;
110
+ /** Packages whose hash does not re-derive (baselined or not). */
111
+ broken: number;
112
+ /** Of {@link broken}, how many an accurate baseline entry covers. */
113
+ baselined: number;
114
+ /** Packages whose prompt carries a pre-loaded verdict. */
115
+ steered: number;
116
+ /** Of {@link steered}, how many carry a phrase-pinned `steeredAck`. */
117
+ steeringAcknowledged: number;
118
+ }
119
+
120
+ export function sha256(buf: Buffer | string): string {
121
+ return crypto.createHash('sha256').update(buf).digest('hex');
122
+ }
123
+
124
+ /**
125
+ * Read the baseline, keyed by slug.
126
+ *
127
+ * A malformed baseline is an internal error, never an empty map: silently
128
+ * treating an unparseable suppression file as "no exemptions" would red the gate
129
+ * for the wrong reason, and treating it as "everything exempt" would disarm it.
130
+ */
131
+ export function loadBaseline(file: string): Map<string, BaselineEntry> {
132
+ const out = new Map<string, BaselineEntry>();
133
+ let raw: string;
134
+ try {
135
+ raw = fs.readFileSync(file, 'utf-8');
136
+ } catch {
137
+ return out; // absent baseline = no exemptions, which is the strict state
138
+ }
139
+ const parsed = JSON.parse(raw) as { entries?: unknown };
140
+ const entries = parsed.entries;
141
+ if (!Array.isArray(entries)) {
142
+ throw new Error(`${file}: expected an \`entries\` array`);
143
+ }
144
+ for (const e of entries) {
145
+ const entry = e as Partial<BaselineEntry>;
146
+ if (
147
+ typeof entry.slug !== 'string' ||
148
+ typeof entry.declared !== 'string' ||
149
+ typeof entry.actual !== 'string' ||
150
+ typeof entry.reason !== 'string'
151
+ ) {
152
+ throw new Error(`${file}: entry missing slug / declared / actual / reason`);
153
+ }
154
+ // A duplicate slug would silently overwrite the earlier entry, dropping
155
+ // its reason and its falsifier — the precise hole the suppression
156
+ // ratchet exists to prevent, and invisible to it because the file still
157
+ // parses.
158
+ if (out.has(entry.slug)) {
159
+ throw new Error(`${file}: duplicate entry for slug '${entry.slug}'`);
160
+ }
161
+ const ack = entry.steeredAck;
162
+ if (ack !== undefined && (typeof ack.phrase !== 'string' || typeof ack.reason !== 'string')) {
163
+ throw new Error(`${file}: ${entry.slug}: steeredAck needs a phrase and a reason`);
164
+ }
165
+ out.set(entry.slug, {
166
+ slug: entry.slug,
167
+ declared: entry.declared.toLowerCase(),
168
+ actual: entry.actual.toLowerCase(),
169
+ reason: entry.reason,
170
+ ...(ack === undefined ? {} : { steeredAck: ack }),
171
+ });
172
+ }
173
+ return out;
174
+ }
175
+
176
+ interface Package {
177
+ slug: string;
178
+ /** Repo-relative, for messages. */
179
+ artefact: string;
180
+ declared: string;
181
+ promptPath: string;
182
+ }
183
+
184
+ /**
185
+ * The checkable set: an artefact with a `prompt_hash` AND a committed prompt.
186
+ *
187
+ * Either half absent means there is nothing to compare — exactly as before this
188
+ * gate existed. That is a deliberate non-finding: `prompt_hash` is an additive
189
+ * v1 field, so most historical artefacts predate it, and requiring a package
190
+ * retroactively would turn a real check into a migration event.
191
+ */
192
+ export function collectPackages(reviewsDirAbs: string, repo: string, ledger: GateLedger): Package[] {
193
+ const names = fs
194
+ .readdirSync(reviewsDirAbs)
195
+ .filter((n) => n.endsWith('.findings.md'))
196
+ .sort();
197
+ ledger.plan(names);
198
+
199
+ const out: Package[] = [];
200
+ for (const name of names) {
201
+ const slug = name.replace(/\.findings\.md$/, '');
202
+ const abs = path.join(reviewsDirAbs, name);
203
+ const declared = parseArtifact(fs.readFileSync(abs, 'utf-8')).marker?.promptHash;
204
+ if (declared === undefined) {
205
+ ledger.outOfScope(name, 'not_applicable_kind');
206
+ continue;
207
+ }
208
+ const promptPath = path.join(reviewsDirAbs, `${slug}.review-input`, 'prompt.md');
209
+ if (!fs.existsSync(promptPath)) {
210
+ ledger.skip(name, 'no_applicable_files');
211
+ continue;
212
+ }
213
+ out.push({
214
+ slug,
215
+ artefact: path.relative(repo, abs).split(path.sep).join('/'),
216
+ declared: declared.toLowerCase(),
217
+ promptPath,
218
+ });
219
+ }
220
+ return out;
221
+ }
222
+
223
+ export function evaluate(
224
+ packages: readonly Package[],
225
+ baseline: Map<string, BaselineEntry>,
226
+ ledger: GateLedger,
227
+ ): { findings: Finding[]; tally: Tally } {
228
+ const findings: Finding[] = [];
229
+ const tally: Tally = {
230
+ packages: packages.length,
231
+ binding: 0,
232
+ broken: 0,
233
+ baselined: 0,
234
+ steered: 0,
235
+ steeringAcknowledged: 0,
236
+ };
237
+ const matched = new Set<string>();
238
+
239
+ for (const pkg of packages) {
240
+ const name = `${pkg.slug}.findings.md`;
241
+ // ONE read: the hash must cover exactly the bytes the steering predicate
242
+ // screened. Two reads leave a window in which they could differ, which
243
+ // would publish a hash that does not correspond to the text that was
244
+ // checked — undetectable downstream.
245
+ const buf = fs.readFileSync(pkg.promptPath);
246
+ const text = buf.toString('utf-8');
247
+ const actual = sha256(buf);
248
+ let failed: string | null = null;
249
+ const entry = baseline.get(pkg.slug);
250
+
251
+ // Steering is checked on EVERY package, including one whose hash is
252
+ // baselined: the hash exemption covers a hash, never the content. It can
253
+ // only be acknowledged by its own `steeredAck`, pinned to the matched
254
+ // phrase — see the field's docstring for why an acknowledgement path has
255
+ // to exist at all.
256
+ const steer = preloadedVerdict(text);
257
+ if (steer !== null) {
258
+ tally.steered += 1;
259
+ const ack = entry?.steeredAck;
260
+ if (ack !== undefined && ack.phrase === steer) {
261
+ tally.steeringAcknowledged += 1;
262
+ matched.add(pkg.slug);
263
+ } else {
264
+ failed = `pre-loaded verdict in the committed prompt: ${JSON.stringify(steer)}`;
265
+ findings.push({
266
+ kind: 'steered-prompt',
267
+ slug: pkg.slug,
268
+ detail:
269
+ `${pkg.artefact}: the reviewer prompt carries ${JSON.stringify(steer)} — ` +
270
+ 'a verdict authored into the prompt, which is the case-zero failure ' +
271
+ '(evaluator-independence). It is not covered by a hash exemption; ' +
272
+ 'acknowledging it needs a `steeredAck` entry pinning this exact phrase.',
273
+ });
274
+ }
275
+ }
276
+
277
+ if (actual === pkg.declared) {
278
+ tally.binding += 1;
279
+ } else {
280
+ tally.broken += 1;
281
+ if (entry !== undefined && entry.declared === pkg.declared && entry.actual === actual) {
282
+ tally.baselined += 1;
283
+ matched.add(pkg.slug);
284
+ } else if (entry !== undefined) {
285
+ matched.add(pkg.slug);
286
+ failed = 'baselined break moved';
287
+ findings.push({
288
+ kind: 'stale-baseline',
289
+ slug: pkg.slug,
290
+ detail:
291
+ `${pkg.artefact}: baselined as declared=${entry.declared} actual=${entry.actual}, ` +
292
+ `but the corpus now reads declared=${pkg.declared} actual=${actual}. ` +
293
+ 'The record moved — repair it or re-record the entry with the reason it changed.',
294
+ });
295
+ } else {
296
+ failed = 'binding broken';
297
+ findings.push({
298
+ kind: 'binding-broken',
299
+ slug: pkg.slug,
300
+ detail:
301
+ `${pkg.artefact}: prompt_hash ${pkg.declared} does not re-derive from ` +
302
+ `${path.basename(path.dirname(pkg.promptPath))}/prompt.md (sha256 ${actual}). ` +
303
+ 'The verdict is no longer attributable to the prompt that produced it.',
304
+ });
305
+ }
306
+ }
307
+
308
+ if (failed === null) {
309
+ ledger.complete(name);
310
+ } else {
311
+ ledger.fail(name, failed);
312
+ }
313
+ }
314
+
315
+ // A baseline entry naming a slug the corpus no longer carries is a hole the
316
+ // ratchet cannot see: it would keep exempting a record that is gone, and the
317
+ // day a package returns under that slug it would be exempt for free.
318
+ for (const [slug, entry] of baseline) {
319
+ if (!matched.has(slug)) {
320
+ findings.push({
321
+ kind: 'stale-baseline',
322
+ slug,
323
+ detail:
324
+ `baseline entry '${slug}' matches no broken binding in the corpus ` +
325
+ `(reason recorded: ${entry.reason.slice(0, 80)}…) — the record was repaired or ` +
326
+ 'removed, so the entry must go with it.',
327
+ });
328
+ }
329
+ }
330
+
331
+ return { findings, tally };
332
+ }
333
+
334
+ /**
335
+ * `--self-test`: prove the CLI still rejects, not just that the functions do.
336
+ *
337
+ * The fixtures are the two failures the roadmap named — a one-byte prompt edit
338
+ * and a pre-loaded verdict — plus an intact corpus, so a suite that only ever
339
+ * rejects would be visible as a suite that discriminates nothing.
340
+ */
341
+ function selfTest(): number {
342
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), 'prompt-binding-selftest-'));
343
+ const marker = (h: string): string =>
344
+ `<!-- completion-review: v1 | reviewed: 2026-08-13 | scope: ${'a'.repeat(64)} | ` +
345
+ `diff: abc1234 | reviewer: r2-fresh-subagent-x | prompt_hash: ${h} -->`;
346
+
347
+ const build = (slug: string, prompt: string, declared: string): string => {
348
+ const dir = fs.mkdtempSync(path.join(root, 'corpus-'));
349
+ const reviews = path.join(dir, 'reviews');
350
+ fs.mkdirSync(path.join(reviews, `${slug}.review-input`), { recursive: true });
351
+ fs.writeFileSync(
352
+ path.join(reviews, `${slug}.findings.md`),
353
+ `# Findings: ${slug}\n${marker(declared)}\n`,
354
+ 'utf-8',
355
+ );
356
+ fs.writeFileSync(path.join(reviews, `${slug}.review-input`, 'prompt.md'), prompt, 'utf-8');
357
+ return dir;
358
+ };
359
+
360
+ const invoke = (corpus: string): number =>
361
+ runGateCli(
362
+ REPO_ROOT,
363
+ 'src/scripts/check_review_prompt_binding.ts',
364
+ // No baseline: a self-test that could be silenced by the shipped
365
+ // exemptions would prove the exemptions, not the detection.
366
+ ['--repo', corpus, '--reviews-dir', 'reviews', '--baseline', 'absent-baseline.json'],
367
+ corpus,
368
+ );
369
+
370
+ const intact = 'Review the diff against the roadmap and report findings.\n';
371
+ const cases: SelfTestCase[] = [
372
+ {
373
+ name: 'intact binding is accepted',
374
+ expect: 'accept',
375
+ run: () => invoke(build('intact', intact, sha256(intact))),
376
+ },
377
+ {
378
+ name: 'a one-byte prompt edit breaks the binding',
379
+ expect: 'reject',
380
+ run: () => invoke(build('edited', `${intact}.`, sha256(intact))),
381
+ },
382
+ {
383
+ name: 'a pre-loaded verdict in the prompt is caught even when the hash re-derives',
384
+ expect: 'reject',
385
+ run: () => {
386
+ const steered = `${intact}NO-FINDINGS is expected and welcome.\n`;
387
+ return invoke(build('steered', steered, sha256(steered)));
388
+ },
389
+ },
390
+ ];
391
+
392
+ try {
393
+ return runSelfTest({ gate: 'check_review_prompt_binding', cases, minCases: 3, minRejectCases: 2 });
394
+ } finally {
395
+ fs.rmSync(root, { recursive: true, force: true });
396
+ }
397
+ }
398
+
399
+ interface Args {
400
+ repo: string;
401
+ reviewsDir: string;
402
+ baseline: string;
403
+ json: boolean;
404
+ }
405
+
406
+ function parseArgs(argv: readonly string[]): Args {
407
+ const args: Args = {
408
+ repo: REPO_ROOT,
409
+ reviewsDir: DEFAULT_REVIEWS_DIR,
410
+ baseline: DEFAULT_BASELINE,
411
+ json: false,
412
+ };
413
+ for (let i = 0; i < argv.length; i++) {
414
+ const a = argv[i] as string;
415
+ const take = (): string => {
416
+ const v = argv[++i];
417
+ if (v === undefined) {
418
+ process.stderr.write(`check_review_prompt_binding: ${a} expects one argument\n`);
419
+ // § 6: the count is emitted on EVERY exit path, exit 2 included —
420
+ // the coverage guard must never be left without a number.
421
+ process.stdout.write('scanned: 0\n');
422
+ process.exit(2);
423
+ }
424
+ return v;
425
+ };
426
+ if (a === '--repo') args.repo = path.resolve(take());
427
+ else if (a === '--reviews-dir') args.reviewsDir = take();
428
+ else if (a === '--baseline') args.baseline = take();
429
+ else if (a === '--json') args.json = true;
430
+ else if (a === '-h' || a === '--help') {
431
+ process.stdout.write(
432
+ 'usage: check_review_prompt_binding [--repo DIR] [--reviews-dir REL] ' +
433
+ '[--baseline REL] [--json]\n',
434
+ );
435
+ process.exit(0);
436
+ } else {
437
+ process.stderr.write(`check_review_prompt_binding: unrecognized argument: ${a}\n`);
438
+ process.stdout.write('scanned: 0\n');
439
+ process.exit(2);
440
+ }
441
+ }
442
+ return args;
443
+ }
444
+
445
+ export function main(argv?: readonly string[]): number {
446
+ const raw = argv ?? process.argv.slice(2);
447
+ if (raw.includes('--self-test')) {
448
+ return selfTest();
449
+ }
450
+ const args = parseArgs(raw);
451
+ const reviewsDirAbs = path.isAbsolute(args.reviewsDir)
452
+ ? args.reviewsDir
453
+ : path.join(args.repo, args.reviewsDir);
454
+ const baselineAbs = path.isAbsolute(args.baseline)
455
+ ? args.baseline
456
+ : path.join(args.repo, args.baseline);
457
+
458
+ const ledger = new GateLedger('check_review_prompt_binding');
459
+ let packages: Package[];
460
+ let baseline: Map<string, BaselineEntry>;
461
+ try {
462
+ packages = collectPackages(reviewsDirAbs, args.repo, ledger);
463
+ baseline = loadBaseline(baselineAbs);
464
+ } catch (exc) {
465
+ process.stderr.write(
466
+ `❌ check_review_prompt_binding: ${exc instanceof Error ? exc.message : String(exc)}\n`,
467
+ );
468
+ process.stdout.write('scanned: 0\n');
469
+ return 2;
470
+ }
471
+
472
+ // In `--json` mode stdout carries the payload and NOTHING else, because both
473
+ // baseline entries name `--json` as their falsifier and a falsifier that has
474
+ // to be un-interleaved before it parses is not re-runnable. The human lines,
475
+ // the ledger and the `scanned:` count all move to stderr there.
476
+ const out = args.json
477
+ ? process.stderr.write.bind(process.stderr)
478
+ : process.stdout.write.bind(process.stdout);
479
+
480
+ let findings: Finding[];
481
+ let tally: Tally;
482
+ try {
483
+ // The count is the CHECKABLE set, not the directory listing: an artefact
484
+ // with no prompt package was not inspected, and counting it would be the
485
+ // false-coverage claim `scanned:` exists to prevent. `reportScanned`
486
+ // asserts and publishes the SAME number — splitting the two is how a
487
+ // published count drifts from the validated one.
488
+ reportScanned(
489
+ {
490
+ gate: 'check_review_prompt_binding',
491
+ scanned: packages.length,
492
+ units: 'reviewer prompt package(s)',
493
+ roots: [args.reviewsDir],
494
+ },
495
+ out,
496
+ );
497
+ ({ findings, tally } = evaluate(packages, baseline, ledger));
498
+ } catch (exc) {
499
+ if (exc instanceof DeadScopeError) {
500
+ process.stderr.write(`❌ ${exc.message}\n`);
501
+ return 1;
502
+ }
503
+ // Anything else here is an internal error, and § 6 maps those to 2 so a
504
+ // broken gate cannot block the change that repairs it. Letting it throw
505
+ // would exit 1 — the "policy violation, block" code — inverting the
506
+ // contract for exactly the failures the docstring promises are exit 2.
507
+ process.stderr.write(
508
+ `❌ check_review_prompt_binding: ${exc instanceof Error ? exc.message : String(exc)}\n`,
509
+ );
510
+ return 2;
511
+ }
512
+
513
+ ledger.report(out);
514
+ out(
515
+ `prompt bindings: ${String(tally.packages)} package(s) · ${String(tally.binding)} binding · ` +
516
+ `${String(tally.steered)} steered (${String(tally.steeringAcknowledged)} acknowledged) · ` +
517
+ `${String(tally.broken)} broken (${String(tally.baselined)} baselined)\n`,
518
+ );
519
+
520
+ if (args.json) {
521
+ process.stdout.write(`${JSON.stringify({ tally, findings }, null, 2)}\n`);
522
+ }
523
+
524
+ if (findings.length === 0) {
525
+ return 0;
526
+ }
527
+ process.stderr.write(`\n❌ ${String(findings.length)} prompt-binding finding(s):\n`);
528
+ for (const f of findings) {
529
+ process.stderr.write(` [${f.kind}] ${f.detail}\n`);
530
+ }
531
+ return 1;
532
+ }
533
+
534
+ function _isCliEntry(): boolean {
535
+ if (process.argv[1] === undefined) {
536
+ return false;
537
+ }
538
+ if (import.meta.url === pathToFileURL(path.resolve(process.argv[1])).href) {
539
+ return true;
540
+ }
541
+ try {
542
+ return (
543
+ fs.realpathSync(fileURLToPath(import.meta.url)) ===
544
+ fs.realpathSync(path.resolve(process.argv[1]))
545
+ );
546
+ } catch {
547
+ return false;
548
+ }
549
+ }
550
+
551
+ if (_isCliEntry()) {
552
+ process.exit(main());
553
+ }