@event4u/agent-config 10.1.0 → 10.3.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 (158) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/CHANGELOG.md +110 -167
  3. package/README.md +1 -1
  4. package/dist/agent-src/commands/analyze/reference-repo.md +62 -0
  5. package/dist/agent-src/commands/roadmap/create.md +4 -1
  6. package/dist/agent-src/commands/roadmap/next.md +38 -0
  7. package/dist/agent-src/contexts/communication/rules-auto/token-efficiency-mechanics.md +22 -1
  8. package/dist/agent-src/contexts/contracts/frugality-charter.md +1 -1
  9. package/dist/agent-src/personas/advisors/first-principles.md +6 -0
  10. package/dist/agent-src/rules/active-remediation.md +53 -2
  11. package/dist/agent-src/rules/code-provenance.md +66 -4
  12. package/dist/agent-src/rules/content-quoting-floor.md +6 -0
  13. package/dist/agent-src/rules/context-hygiene.md +39 -0
  14. package/dist/agent-src/rules/design-fidelity.md +49 -3
  15. package/dist/agent-src/rules/token-budget-discipline.md +3 -2
  16. package/dist/agent-src/rules/token-efficiency.md +22 -0
  17. package/dist/agent-src/skills/ai-council/SKILL.md +28 -6
  18. package/dist/agent-src/skills/condense-memory/SKILL.md +1 -1
  19. package/dist/agent-src/skills/design-review/references/verification-automation.md +23 -1
  20. package/dist/agent-src/skills/fe-design/SKILL.md +27 -3
  21. package/dist/agent-src/skills/judge-synthesis/SKILL.md +30 -0
  22. package/dist/agent-src/skills/learning-to-rule-or-skill/SKILL.md +11 -0
  23. package/dist/agent-src/skills/roadmap-writing/SKILL.md +9 -9
  24. package/dist/agent-src/skills/skill-writing/SKILL.md +4 -0
  25. package/dist/agent-src/skills/token-optimizer/SKILL.md +5 -1
  26. package/dist/agent-src/templates/agent-settings.md +12 -28
  27. package/dist/agent-src/templates/agents/agent-project-settings.example.yml +2 -2
  28. package/dist/agent-src/templates/persona.md +26 -0
  29. package/dist/agent-src/templates/roadmaps.md +27 -7
  30. package/dist/agent-src/templates/scripts/work_engine/_lib/agent_settings.ts +0 -1
  31. package/dist/agent-src/templates/skill.md +35 -1
  32. package/dist/cli/commands/commands.candidates.test.js +257 -0
  33. package/dist/cli/commands/commands.candidates.test.js.map +1 -0
  34. package/dist/cli/commands/commands.js +199 -0
  35. package/dist/cli/commands/commands.js.map +1 -1
  36. package/dist/cli/main.js +1 -0
  37. package/dist/cli/main.js.map +1 -1
  38. package/dist/cli/registry.js +2 -2
  39. package/dist/cli/registry.js.map +1 -1
  40. package/dist/cli-delegate/{chunk-3JUTT26I.js → chunk-6ENSXSBW.js} +1 -1
  41. package/dist/cli-delegate/{chunk-TVGXH7VA.js → chunk-6LOPHJLD.js} +1 -1
  42. package/dist/cli-delegate/{chunk-XT2YTJCZ.js → chunk-AYKQGIBG.js} +4 -0
  43. package/dist/cli-delegate/{chunk-HJSKTMIF.js → chunk-BV3WR67D.js} +3 -19
  44. package/dist/cli-delegate/{chunk-HTIU3W56.js → chunk-BWMXLD4B.js} +6 -2
  45. package/dist/cli-delegate/{chunk-2H5SE7DU.js → chunk-FV34EGEX.js} +166 -39
  46. package/dist/cli-delegate/{chunk-XTC5ARB6.js → chunk-IR3IVRVC.js} +7 -2
  47. package/dist/cli-delegate/{chunk-LRXJK6GN.js → chunk-JMQZ7MM5.js} +1 -1
  48. package/dist/cli-delegate/{chunk-NX5AXSLD.js → chunk-X3VOARZD.js} +3 -3
  49. package/dist/cli-delegate/cmd_analyze_session.js +1 -1
  50. package/dist/cli-delegate/cmd_brand_status.js +1 -1
  51. package/dist/cli-delegate/cmd_conformance.js +881 -204
  52. package/dist/cli-delegate/cmd_doctor.js +3 -3
  53. package/dist/cli-delegate/cmd_explain.js +2 -2
  54. package/dist/cli-delegate/cmd_export.js +4 -4
  55. package/dist/cli-delegate/cmd_handoff.js +6 -5
  56. package/dist/cli-delegate/cmd_mcp_available.js +1 -1
  57. package/dist/cli-delegate/cmd_migrate.js +2 -2
  58. package/dist/cli-delegate/cmd_packs_active.js +2 -2
  59. package/dist/cli-delegate/cmd_prune.js +4 -4
  60. package/dist/cli-delegate/cmd_route_audit.js +2 -2
  61. package/dist/cli-delegate/cmd_route_explain.js +1 -1
  62. package/dist/cli-delegate/cmd_session_recycle.js +44 -9
  63. package/dist/cli-delegate/cmd_settings_get.js +2 -2
  64. package/dist/cli-delegate/cmd_settings_migrate.js +4 -4
  65. package/dist/cli-delegate/cmd_settings_set.js +2 -2
  66. package/dist/cli-delegate/cmd_sync.js +4 -4
  67. package/dist/cli-delegate/cmd_uninstall.js +4 -4
  68. package/dist/cli-delegate/cmd_update.js +1 -1
  69. package/dist/cli-delegate/cmd_upgrade.js +1 -1
  70. package/dist/cli-delegate/cmd_validate.js +4 -4
  71. package/dist/cli-delegate/cmd_versions.js +1 -1
  72. package/dist/discovery/deprecation-report.md +1 -1
  73. package/dist/discovery/discovery-manifest.json +21 -21
  74. package/dist/discovery/discovery-manifest.json.sha256 +1 -1
  75. package/dist/discovery/discovery-manifest.summary.md +1 -1
  76. package/dist/discovery/dormancy-report.md +1 -1
  77. package/dist/discovery/orphan-report.md +1 -1
  78. package/dist/discovery/packs.json +2 -2
  79. package/dist/discovery/trust-report.md +1 -1
  80. package/dist/discovery/workspaces.json +2 -2
  81. package/dist/hooks/dispatch.js +204 -44
  82. package/dist/install/install.mjs +9 -21
  83. package/dist/mcp/registry-manifest.json +2 -2
  84. package/dist/mcp/server.json +2 -2
  85. package/dist/mcp/server.mjs +1337 -490
  86. package/dist/router.json +1 -1
  87. package/dist/scripts/_lib/agent_settings.js +25 -2
  88. package/dist/scripts/_lib/agent_settings.js.map +1 -1
  89. package/dist/scripts/_lib/compile_time_toggles.js +4 -3
  90. package/dist/scripts/_lib/compile_time_toggles.js.map +1 -1
  91. package/dist/server/io/yamlIO.js +0 -2
  92. package/dist/server/io/yamlIO.js.map +1 -1
  93. package/dist/server/schemas/settings.js +3 -9
  94. package/dist/server/schemas/settings.js.map +1 -1
  95. package/dist/shared/settingsClasses.js +64 -2
  96. package/dist/shared/settingsClasses.js.map +1 -1
  97. package/dist/ui/assets/{index-B5qGYOJo.js → index-5fXOHoVh.js} +2 -2
  98. package/dist/ui/assets/index-5fXOHoVh.js.map +1 -0
  99. package/dist/ui/index.html +1 -1
  100. package/docs/contracts/settings-classes.md +307 -145
  101. package/docs/guidelines/agent-infra/active-remediation-mechanics.md +54 -0
  102. package/docs/guidelines/agent-infra/layered-settings.md +1 -2
  103. package/docs/guidelines/agent-infra/symptom-driven-harvest-loop.md +104 -0
  104. package/docs/guidelines/design-antipatterns.md +79 -10
  105. package/docs/guidelines/design-fidelity-mechanics.md +120 -2
  106. package/package.json +1 -1
  107. package/src/agent-src/templates/scripts/work_engine/_lib/agent_settings.ts +0 -1
  108. package/src/config/agent-settings.template.yml +12 -30
  109. package/src/config/gate-coverage.yml +42 -8
  110. package/src/config/gate-violation-baselines.json +15 -3
  111. package/src/config/profiles/balanced.ini +0 -2
  112. package/src/config/profiles/full.ini +1 -3
  113. package/src/config/profiles/minimal.ini +0 -2
  114. package/src/config/review-prompt-binding-baseline.json +19 -0
  115. package/src/config/rule-enforcement-baseline.json +0 -1
  116. package/src/scripts/_cli/cmd_session_recycle.ts +66 -9
  117. package/src/scripts/_cli/handoff_sessions.ts +3 -2
  118. package/src/scripts/_dispatch.bash +1 -0
  119. package/src/scripts/_lib/agent_settings.ts +25 -2
  120. package/src/scripts/_lib/bench_telegraph.ts +1 -1
  121. package/src/scripts/_lib/cc_transcript.ts +25 -0
  122. package/src/scripts/_lib/compile_time_toggles.ts +4 -3
  123. package/src/scripts/ai_council/clients.ts +243 -34
  124. package/src/scripts/cache_realization_report.ts +3 -2
  125. package/src/scripts/check_ci_local_parity.ts +60 -6
  126. package/src/scripts/check_review_prompt_binding.ts +553 -0
  127. package/src/scripts/check_rule_projection_integrity.ts +3 -1
  128. package/src/scripts/check_suppression_hygiene.ts +19 -3
  129. package/src/scripts/conformance_scan.ts +282 -9
  130. package/src/scripts/cost/track.mjs +10 -4
  131. package/src/scripts/council_cli.ts +33 -3
  132. package/src/scripts/design_slop_rules.test.ts +37 -6
  133. package/src/scripts/design_slop_rules.ts +150 -22
  134. package/src/scripts/dispatch_r2_reviewer.ts +15 -1
  135. package/src/scripts/external_sources_denylist.json +3 -1
  136. package/src/scripts/hook_manifest.yaml +17 -6
  137. package/src/scripts/hooks/block_unauthorized_git.ts +54 -8
  138. package/src/scripts/hooks/delegation_nudge_hook.ts +33 -8
  139. package/src/scripts/hooks/evidence_independence.ts +61 -9
  140. package/src/scripts/hooks/session_eol_hook.ts +123 -3
  141. package/src/scripts/hooks/turn_end_gate_hook.ts +31 -0
  142. package/src/scripts/hooks/ui_route_nudge_hook.ts +55 -2
  143. package/src/scripts/lint_design_antipattern_parity.ts +201 -0
  144. package/src/scripts/lint_harvest_provenance.ts +483 -0
  145. package/src/scripts/lint_settings_classes.ts +273 -11
  146. package/src/scripts/lint_skill_router_head.ts +254 -0
  147. package/src/scripts/lint_symptom_intake.ts +241 -0
  148. package/src/scripts/report_consultation_rate.ts +128 -2
  149. package/src/scripts/report_skill_activation.ts +6 -4
  150. package/src/scripts/report_skill_obligation_violations.ts +2 -1
  151. package/src/scripts/run_skill_evals.ts +10 -5
  152. package/src/scripts/schemas/persona.schema.json +8 -0
  153. package/src/scripts/schemas/provenance-harvest.schema.json +39 -0
  154. package/src/scripts/sweep_dead_scan_roots.ts +51 -0
  155. package/src/server/io/yamlIO.ts +0 -2
  156. package/src/server/schemas/settings.ts +3 -19
  157. package/src/shared/settingsClasses.ts +93 -2
  158. package/dist/ui/assets/index-B5qGYOJo.js.map +0 -1
@@ -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
+ }
@@ -5,7 +5,9 @@
5
5
  *
6
6
  * WHY THIS GATE EXISTS (road-to-conformance-round5 Phase 1, measured 2026-08-07)
7
7
  * ---------------------------------------------------------------------------
8
- * `docs/contracts/rule-router.md:234` states that nothing loads
8
+ * `docs/contracts/rule-router.md` (§ "The router earns its place as a
9
+ * **compile-time** artifact", measured: zero consumers under
10
+ * `src/scripts/hooks/`) states that nothing loads
9
11
  * `dist/router.json` at runtime, so **projection is the only reach mechanism
10
12
  * there is**: a non-kernel rule activates by the model's judgment over text
11
13
  * already in context, and the per-tool rule tree is what puts it there. That
@@ -227,9 +227,20 @@ export const SUPPRESSION_INVENTORY: readonly SuppressionSpec[] = [
227
227
  file: 'src/config/rule-enforcement-baseline.json',
228
228
  listKey: null,
229
229
  tier: 'string_list',
230
- newInThisChange: true,
231
230
  what: 'rules predating the enforced_by-declaration ratchet',
232
231
  },
232
+ {
233
+ // Two committed prompt→verdict bindings that do not re-derive. Both
234
+ // sit inside round records § 2.7 declares immutable, so neither may be
235
+ // repaired by editing the record — the entry pins BOTH hashes, so a
236
+ // later repair or a further corruption reds instead of passing.
237
+ file: 'src/config/review-prompt-binding-baseline.json',
238
+ listKey: 'entries',
239
+ tier: 'object',
240
+ keyFields: ['slug'],
241
+ newInThisChange: true,
242
+ what: 'review prompts whose recorded prompt_hash does not re-derive',
243
+ },
233
244
  ];
234
245
 
235
246
  export interface SuppressionFinding {
@@ -391,6 +402,12 @@ export function main(): number {
391
402
  const entries = loadEntries(spec);
392
403
  const reasonField = spec.reasonField ?? 'reason';
393
404
 
405
+ // Declared BEFORE the stale-flag branch below, which is a file-level
406
+ // finding: it must feed the single terminal resolution at the end of
407
+ // this iteration rather than resolve the target itself. Resolving twice
408
+ // is a `LedgerUsageError` that aborts the whole gate.
409
+ let fileFindings = 0;
410
+
394
411
  let comparison: RatchetComparison;
395
412
  try {
396
413
  comparison = compareToBaseRef({
@@ -413,8 +430,8 @@ export function main(): number {
413
430
  `\`newInThisChange\` is still set, but the baseline now resolves at ${baseRef} — ` +
414
431
  'the flag has served its purpose and must be removed from SUPPRESSION_INVENTORY. ' +
415
432
  'Leaving it set makes a future mistyped path pass as a new baseline forever.';
416
- ledger.fail(spec.file, detail);
417
433
  findings.push({ file: spec.file, entry: '(whole file)', kind: 'uninventoried', detail });
434
+ fileFindings += 1;
418
435
  }
419
436
  } catch (exc) {
420
437
  if (exc instanceof BaseRefUnavailableError) {
@@ -426,7 +443,6 @@ export function main(): number {
426
443
  }
427
444
 
428
445
  const added = new Set(comparison.added);
429
- let fileFindings = 0;
430
446
 
431
447
  for (const entry of entries) {
432
448
  if (spec.tier === 'object' && entry.reason.length < MIN_REASON_CHARS) {