@event4u/agent-config 9.24.0 → 9.26.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 (97) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/CHANGELOG.md +136 -0
  3. package/README.md +1 -1
  4. package/dist/agent-src/commands/analyze/conformance.md +182 -0
  5. package/dist/agent-src/commands/analyze/inbox.md +33 -2
  6. package/dist/agent-src/commands/review/changes.md +6 -5
  7. package/dist/agent-src/commands/roadmap/next.md +194 -0
  8. package/dist/agent-src/commands/roadmap.md +12 -4
  9. package/dist/agent-src/contexts/execution/subagent-routing.md +4 -1
  10. package/dist/agent-src/rules/decision-revisit-gate.md +2 -0
  11. package/dist/agent-src/rules/delegation-policy.md +3 -2
  12. package/dist/agent-src/rules/design-fidelity.md +17 -1
  13. package/dist/agent-src/rules/design-review-after-ui-write.md +116 -0
  14. package/dist/agent-src/rules/evaluator-independence.md +111 -0
  15. package/dist/agent-src/rules/session-canary.md +15 -1
  16. package/dist/agent-src/rules/settings-ask-protocol.md +176 -0
  17. package/dist/agent-src/rules/token-budget-discipline.md +6 -3
  18. package/dist/agent-src/rules/ui-audit-gate.md +3 -0
  19. package/dist/agent-src/rules/user-interaction.md +23 -0
  20. package/dist/agent-src/skills/finishing-a-development-branch/SKILL.md +1 -1
  21. package/dist/agent-src/skills/recursive-verification/SKILL.md +13 -0
  22. package/dist/agent-src/skills/rule-writing/SKILL.md +45 -0
  23. package/dist/agent-src/skills/skill-writing/SKILL.md +114 -0
  24. package/dist/agent-src/templates/agents/agent-project-settings.example.yml +1 -1
  25. package/dist/cli/registry.js +1 -0
  26. package/dist/cli/registry.js.map +1 -1
  27. package/dist/cli-delegate/cmd_route_audit.js +4 -1
  28. package/dist/cli-delegate/cmd_route_explain.js +4 -1
  29. package/dist/discovery/deprecation-report.md +1 -1
  30. package/dist/discovery/discovery-manifest.json +146 -28
  31. package/dist/discovery/discovery-manifest.json.sha256 +1 -1
  32. package/dist/discovery/discovery-manifest.summary.md +5 -5
  33. package/dist/discovery/orphan-report.md +1 -1
  34. package/dist/discovery/packs.json +16 -11
  35. package/dist/discovery/trust-report.md +3 -3
  36. package/dist/discovery/workspaces.json +12 -7
  37. package/dist/hooks/dispatch.js +1501 -511
  38. package/dist/mcp/registry-manifest.json +3 -3
  39. package/dist/mcp/server.json +2 -2
  40. package/dist/router.json +1 -1
  41. package/dist/server/nicknameResolver.js +116 -0
  42. package/dist/server/nicknameResolver.js.map +1 -0
  43. package/dist/server/routes/ping.js +29 -11
  44. package/dist/server/routes/ping.js.map +1 -1
  45. package/dist/shared/interactiveContext.js +73 -0
  46. package/dist/shared/interactiveContext.js.map +1 -0
  47. package/dist/shared/nicknamePrefill.js +59 -0
  48. package/dist/shared/nicknamePrefill.js.map +1 -0
  49. package/dist/shared/settingsAsks.js +128 -0
  50. package/dist/shared/settingsAsks.js.map +1 -0
  51. package/dist/shared/settingsConsent.js +93 -0
  52. package/dist/shared/settingsConsent.js.map +1 -0
  53. package/package.json +1 -1
  54. package/src/config/agents-paths.yml +15 -0
  55. package/src/config/evaluator-budgets.json +68 -68
  56. package/src/config/gate-coverage.yml +131 -5
  57. package/src/config/gate-violation-baselines.json +11 -0
  58. package/src/scripts/_cli/cmd_route_audit.ts +4 -1
  59. package/src/scripts/_cli/cmd_route_explain.ts +9 -1
  60. package/src/scripts/_dispatch.bash +12 -0
  61. package/src/scripts/ai-video/stitch.sh +27 -9
  62. package/src/scripts/before_complete_hook.ts +146 -5
  63. package/src/scripts/bench_ab_v2_run.ts +272 -16
  64. package/src/scripts/bench_ab_v2_stats.ts +156 -3
  65. package/src/scripts/check_completion_review.ts +80 -3
  66. package/src/scripts/check_corpus_staleness.ts +692 -0
  67. package/src/scripts/check_depth_budget.ts +295 -0
  68. package/src/scripts/check_gate_coverage.ts +67 -0
  69. package/src/scripts/check_hook_bundle_freshness.ts +183 -0
  70. package/src/scripts/conformance_scan.ts +422 -0
  71. package/src/scripts/dispatch_r2_reviewer.ts +31 -2
  72. package/src/scripts/git_authorization_hook.ts +328 -0
  73. package/src/scripts/hook_manifest.yaml +69 -9
  74. package/src/scripts/hooks/block_no_verify.ts +3 -1
  75. package/src/scripts/hooks/block_unauthorized_git.ts +371 -0
  76. package/src/scripts/hooks/concern_registry.ts +8 -0
  77. package/src/scripts/hooks/design_slop_hook.ts +13 -1
  78. package/src/scripts/hooks/dispatch_hook.ts +97 -2
  79. package/src/scripts/hooks/evidence_independence.ts +368 -0
  80. package/src/scripts/language_mirror_hook.ts +456 -0
  81. package/src/scripts/lint_abstraction_thresholds.ts +41 -8
  82. package/src/scripts/lint_agents_layout.ts +2 -0
  83. package/src/scripts/lint_decision_impact.ts +246 -0
  84. package/src/scripts/lint_example_fences.ts +609 -0
  85. package/src/scripts/lint_hook_manifest.ts +29 -0
  86. package/src/scripts/lint_rule_interactions.ts +90 -0
  87. package/src/scripts/lint_rule_migration_ledger.ts +480 -0
  88. package/src/scripts/lint_skill_descriptions.ts +86 -2
  89. package/src/scripts/memory_learn_hook.ts +109 -6
  90. package/src/scripts/pr_url_reminder_hook.ts +17 -2
  91. package/src/scripts/release.ts +38 -1
  92. package/src/scripts/schemas/review-findings.schema.json +99 -0
  93. package/src/scripts/schemas/rule.schema.json +10 -0
  94. package/src/shared/interactiveContext.ts +98 -0
  95. package/src/shared/nicknamePrefill.ts +96 -0
  96. package/src/shared/settingsAsks.ts +176 -0
  97. package/src/shared/settingsConsent.ts +136 -0
@@ -0,0 +1,422 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Behavioural conformance scan — replays the local transcript store through the
4
+ * checks this suite MECHANISES, and nothing else.
5
+ *
6
+ * The scope constraint is the point, and it is a council ruling
7
+ * (2026-08-06): *"a conformance scan that checks un-mechanised rules is
8
+ * theatre — if you can't gate it, don't pretend measuring it post-hoc is
9
+ * enforcement."* So this scan carries exactly four checks, one per shipped
10
+ * gate, and every classifier is IMPORTED from the gate it measures rather than
11
+ * re-implemented here. A second copy of a classifier is the "second artefact to
12
+ * keep in sync" the repo's own principle forbids, and it would let the scan and
13
+ * the gate disagree silently.
14
+ *
15
+ * language-pin ← language_mirror_hook.classify
16
+ * git-authorization ← git_authorization_hook.classifyAuthorization
17
+ * + block_unauthorized_git.commandOp / BLOCK_OPS
18
+ * vacuous-evidence ← before_complete_hook.isVacuousOutput / isCiPoll / pendingCount
19
+ * evidence-steering ← evidence_independence.isEvaluationPrompt / preloadedVerdict
20
+ *
21
+ * Deliberately NOT scanned: ask-shape, session-canary, promissory closings,
22
+ * checkbox batching, symptom-vs-root-cause. Those are left as prose by
23
+ * `road-to-agent-behavior-conformance`, and measuring them here would claim a
24
+ * rigour the tree does not have.
25
+ *
26
+ * This is a REPORT, not a gate: exit 0 on every path except an unreadable
27
+ * store. It answers "did the shipped gates have anything to bite on?" —
28
+ * which is also the only honest way to find out whether they help.
29
+ */
30
+ import fs from "node:fs";
31
+ import path from "node:path";
32
+ import { fileURLToPath, pathToFileURL } from "node:url";
33
+
34
+ import { classify } from "./language_mirror_hook.js";
35
+ import { classifyAuthorization, type GitOp } from "./git_authorization_hook.js";
36
+ import { BLOCK_OPS, commandOp } from "./hooks/block_unauthorized_git.js";
37
+ import { isVacuousOutput, isCiPoll, pendingCount } from "./before_complete_hook.js";
38
+ import { isEvaluationPrompt, isSelfScoped, preloadedVerdict } from "./hooks/evidence_independence.js";
39
+
40
+ export interface Violation {
41
+ check: "language-pin" | "git-authorization" | "vacuous-evidence" | "evidence-steering";
42
+ session: string;
43
+ at: string;
44
+ detail: string;
45
+ }
46
+
47
+ export interface SessionReport {
48
+ session: string;
49
+ user_turns: number;
50
+ assistant_turns: number;
51
+ violations: Violation[];
52
+ }
53
+
54
+ export interface ScanReport {
55
+ scanned_at: string;
56
+ store: string;
57
+ sessions: number;
58
+ totals: Record<string, number>;
59
+ per_session: SessionReport[];
60
+ }
61
+
62
+ type Entry = Record<string, unknown>;
63
+
64
+ function _isObject(v: unknown): v is Record<string, unknown> {
65
+ return typeof v === "object" && v !== null && !Array.isArray(v);
66
+ }
67
+
68
+ /** Text of a user-role entry, or null when it is not a real chat message. */
69
+ export function userText(entry: Entry): string | null {
70
+ if (entry["type"] !== "user" || entry["isSidechain"] === true) {
71
+ return null;
72
+ }
73
+ const msg = entry["message"];
74
+ const content = _isObject(msg) ? msg["content"] : undefined;
75
+ let text = "";
76
+ if (typeof content === "string") {
77
+ text = content;
78
+ } else if (Array.isArray(content)) {
79
+ text = content
80
+ .filter((b) => _isObject(b) && b["type"] === "text")
81
+ .map((b) => String((b as Record<string, unknown>)["text"] ?? ""))
82
+ .join("\n");
83
+ }
84
+ if (!text.trim()) {
85
+ return null;
86
+ }
87
+ if (text.includes("<system-reminder>") || text.startsWith("<local-command-stdout>")) {
88
+ return null;
89
+ }
90
+ return text;
91
+ }
92
+
93
+ /** Assistant prose, or null. */
94
+ export function assistantText(entry: Entry): string | null {
95
+ if (entry["type"] !== "assistant" || entry["isSidechain"] === true) {
96
+ return null;
97
+ }
98
+ const msg = entry["message"];
99
+ const content = _isObject(msg) ? msg["content"] : undefined;
100
+ if (!Array.isArray(content)) {
101
+ return null;
102
+ }
103
+ const text = content
104
+ .filter((b) => _isObject(b) && b["type"] === "text")
105
+ .map((b) => String((b as Record<string, unknown>)["text"] ?? ""))
106
+ .join("\n")
107
+ .trim();
108
+ return text || null;
109
+ }
110
+
111
+ /** Tool-use blocks from an assistant entry. */
112
+ function toolUses(entry: Entry): Array<{ name: string; input: Record<string, unknown> }> {
113
+ const msg = entry["message"];
114
+ const content = _isObject(msg) ? msg["content"] : undefined;
115
+ if (!Array.isArray(content)) {
116
+ return [];
117
+ }
118
+ return content
119
+ .filter((b) => _isObject(b) && b["type"] === "tool_use")
120
+ .map((b) => {
121
+ const o = b as Record<string, unknown>;
122
+ return {
123
+ name: String(o["name"] ?? ""),
124
+ input: _isObject(o["input"]) ? (o["input"] as Record<string, unknown>) : {},
125
+ };
126
+ });
127
+ }
128
+
129
+ /**
130
+ * A skill / slash-command body arriving in the user role. This is the
131
+ * transcript shape that made the language defect invisible: it occupies the
132
+ * user role but is not a chat message, so it must never become the language
133
+ * trigger — the same reason `language_mirror_hook` pins at prompt-submit time.
134
+ */
135
+ export function isInjectedBody(text: string): boolean {
136
+ if (/^(Base directory for this skill|<command-message>|<command-name>|<local-command)/.test(text)) {
137
+ return true;
138
+ }
139
+ return text.length > 2500 && classify(text).language === "en";
140
+ }
141
+
142
+ /**
143
+ * Harness-generated text that occupies an assistant turn but is not the
144
+ * assistant writing. Round-2 self-scan: 8 of 9 "violations" in one session were
145
+ * `API Error: 529 Overloaded` retry banners. Counting the harness as the model
146
+ * inflates the very number this scan exists to report honestly.
147
+ */
148
+ const HARNESS_TEXT =
149
+ /^(API Error:|Request (timed out|was aborted)|\[Request interrupted|Error: |Credit balance is too low|You've hit your|Prompt is too long)/i;
150
+
151
+ /** Rough English-opener test for the language check (first prose line only). */
152
+ const EN_OPENER =
153
+ /^(let me|i'?ll |i'?m |found it|ok[,. ]|okay|alright|here'?s|now[,. ]|looking|checking|reading|running|the |this |that |there |we |you |good |right[,. ]|perfect|done[.,]|all |confirmed|correct)/i;
154
+
155
+ export function scanSession(sessionId: string, lines: string[]): SessionReport {
156
+ const report: SessionReport = {
157
+ session: sessionId,
158
+ user_turns: 0,
159
+ assistant_turns: 0,
160
+ violations: [],
161
+ };
162
+
163
+ let pinned: "de" | "en" | null = null;
164
+ let pendingPoll: { at: string; cmd: string } | null = null;
165
+ let authorized = new Set<GitOp>();
166
+ let sawPending = false;
167
+ let evaluationsThisTurn = 0;
168
+
169
+ for (const line of lines) {
170
+ if (!line.trim()) {
171
+ continue;
172
+ }
173
+ let entry: Entry;
174
+ try {
175
+ entry = JSON.parse(line) as Entry;
176
+ } catch {
177
+ continue;
178
+ }
179
+
180
+ // Tool results arrive as `user` entries carrying `toolUseResult`.
181
+ if (entry["type"] === "user" && entry["toolUseResult"] !== undefined && pendingPoll !== null) {
182
+ const raw = entry["toolUseResult"];
183
+ const out = typeof raw === "string" ? raw : JSON.stringify(raw);
184
+ const pending = pendingCount(out);
185
+ if (pending !== null && pending > 0) {
186
+ sawPending = true;
187
+ } else if (isVacuousOutput(out) || (pending === 0 && !sawPending)) {
188
+ report.violations.push({
189
+ check: "vacuous-evidence",
190
+ session: sessionId,
191
+ at: pendingPoll.at,
192
+ detail: `CI poll read as settled without an in-flight observation: ${out.slice(0, 90)}`,
193
+ });
194
+ }
195
+ pendingPoll = null;
196
+ continue;
197
+ }
198
+
199
+ const ut = userText(entry);
200
+ if (ut !== null) {
201
+ if (isInjectedBody(ut)) {
202
+ // Not a chat message — it changes neither the pin nor the ledger.
203
+ continue;
204
+ }
205
+ report.user_turns += 1;
206
+ const c = classify(ut);
207
+ if (c.language !== "und") {
208
+ pinned = c.language;
209
+ }
210
+ authorized = new Set(classifyAuthorization(ut).authorized);
211
+ sawPending = false;
212
+ evaluationsThisTurn = 0;
213
+ pendingPoll = null;
214
+ continue;
215
+ }
216
+
217
+ if (entry["type"] !== "assistant" || entry["isSidechain"] === true) {
218
+ continue;
219
+ }
220
+ const at = String(entry["timestamp"] ?? "");
221
+
222
+ const prose = assistantText(entry);
223
+ if (prose !== null) {
224
+ report.assistant_turns += 1;
225
+ if (pinned === "de") {
226
+ const first = prose.split("\n").find((l) => l.trim()) ?? "";
227
+ if (HARNESS_TEXT.test(first.trim())) {
228
+ continue; // harness banner, not the assistant's prose
229
+ }
230
+ const c = classify(first);
231
+ if (first.length > 12 && (c.language === "en" || EN_OPENER.test(first.trim()))) {
232
+ report.violations.push({
233
+ check: "language-pin",
234
+ session: sessionId,
235
+ at,
236
+ detail: `German pin, English reply opener: "${first.slice(0, 110)}"`,
237
+ });
238
+ }
239
+ }
240
+ }
241
+
242
+ for (const tu of toolUses(entry)) {
243
+ const cmd =
244
+ typeof tu.input["command"] === "string" ? (tu.input["command"] as string) : null;
245
+ if (cmd !== null) {
246
+ const op = commandOp(cmd);
247
+ if (op !== null && !authorized.has(op) && BLOCK_OPS.has(op)) {
248
+ report.violations.push({
249
+ check: "git-authorization",
250
+ session: sessionId,
251
+ at,
252
+ detail: `irreversible \`${op}\` with no authorization in the turn's prompt: ${cmd.slice(0, 110)}`,
253
+ });
254
+ }
255
+ if (isCiPoll(cmd)) {
256
+ // The RESULT of this call arrives on the NEXT entry, and that entry is
257
+ // role `user` — not assistant. The first version read
258
+ // `entry["toolUseResult"]` off the assistant entry, where it never
259
+ // exists: measured on a 20 MB transcript, 1913 entries carry the key
260
+ // and all 1913 are `user`. So this check could not fire at all and the
261
+ // scan printed a permanent `✅ vacuous-evidence 0` — a false green in
262
+ // the one report whose job is to say whether the gates bite.
263
+ pendingPoll = { at, cmd };
264
+ }
265
+ }
266
+
267
+ const prompt =
268
+ typeof tu.input["prompt"] === "string" ? (tu.input["prompt"] as string) : null;
269
+ if (prompt !== null && isEvaluationPrompt(prompt)) {
270
+ const preloaded = preloadedVerdict(prompt);
271
+ if (preloaded !== null) {
272
+ report.violations.push({
273
+ check: "evidence-steering",
274
+ session: sessionId,
275
+ at,
276
+ detail: `evaluation prompt pre-loads its verdict: "${preloaded}"`,
277
+ });
278
+ } else if (isSelfScoped(prompt)) {
279
+ if (evaluationsThisTurn >= 1) {
280
+ report.violations.push({
281
+ check: "evidence-steering",
282
+ session: sessionId,
283
+ at,
284
+ detail: "second self-review dispatch in one turn (verdict shopping)",
285
+ });
286
+ }
287
+ evaluationsThisTurn += 1;
288
+ }
289
+ }
290
+ }
291
+ }
292
+
293
+ return report;
294
+ }
295
+
296
+ /** Default Claude Code transcript store for a project directory. */
297
+ export function defaultStore(projectDir: string): string {
298
+ // Claude Code slugs BOTH separators and dots: /Users/x/.claude → -Users-x--claude.
299
+ const slug = projectDir.replace(/[/.]/g, "-");
300
+ return path.join(process.env["HOME"] ?? "", ".claude", "projects", slug);
301
+ }
302
+
303
+ export function scanStore(store: string, limit: number): ScanReport {
304
+ const files = fs
305
+ .readdirSync(store)
306
+ .filter((f) => f.endsWith(".jsonl"))
307
+ .map((f) => ({ f, m: fs.statSync(path.join(store, f)).mtimeMs }))
308
+ .sort((a, b) => b.m - a.m)
309
+ .slice(0, limit)
310
+ .map((x) => x.f);
311
+
312
+ const per_session: SessionReport[] = [];
313
+ for (const f of files) {
314
+ const lines = fs.readFileSync(path.join(store, f), "utf8").split("\n");
315
+ const r = scanSession(f.replace(/\.jsonl$/, "").slice(0, 8), lines);
316
+ if (r.assistant_turns > 0) {
317
+ per_session.push(r);
318
+ }
319
+ }
320
+
321
+ const totals: Record<string, number> = {
322
+ "language-pin": 0,
323
+ "git-authorization": 0,
324
+ "vacuous-evidence": 0,
325
+ "evidence-steering": 0,
326
+ };
327
+ for (const s of per_session) {
328
+ for (const v of s.violations) {
329
+ totals[v.check] = (totals[v.check] ?? 0) + 1;
330
+ }
331
+ }
332
+
333
+ return {
334
+ scanned_at: new Date().toISOString(),
335
+ store,
336
+ sessions: per_session.length,
337
+ totals,
338
+ per_session,
339
+ };
340
+ }
341
+
342
+ export function render(report: ScanReport): string {
343
+ const lines: string[] = [];
344
+ lines.push(`conformance:behavior · ${report.sessions} session(s) · ${report.store}`);
345
+ for (const [k, v] of Object.entries(report.totals)) {
346
+ lines.push(` ${v === 0 ? "✅" : "⚠️ "} ${k.padEnd(18)} ${v}`);
347
+ }
348
+ const worst = [...report.per_session]
349
+ .filter((s) => s.violations.length > 0)
350
+ .sort((a, b) => b.violations.length - a.violations.length)
351
+ .slice(0, 5);
352
+ if (worst.length > 0) {
353
+ lines.push("");
354
+ lines.push(" Highest-violation sessions:");
355
+ for (const s of worst) {
356
+ lines.push(` ${s.session} ${s.violations.length} across ${s.assistant_turns} turns`);
357
+ }
358
+ }
359
+ lines.push("");
360
+ lines.push(" Scanned checks are exactly the mechanised ones. Ask-shape, session-canary,");
361
+ lines.push(" promissory closings and checkbox batching are left as prose and NOT measured.");
362
+ return lines.join("\n");
363
+ }
364
+
365
+ export function main(argv?: string[]): number {
366
+ const args = argv ?? process.argv.slice(2);
367
+ let store: string | null = null;
368
+ let limit = 30;
369
+ let json = false;
370
+ let out: string | null = null;
371
+ for (let i = 0; i < args.length; i += 1) {
372
+ const a = args[i];
373
+ if (a === "--store" && args[i + 1] !== undefined) {
374
+ store = args[++i] as string;
375
+ } else if (a === "--limit" && args[i + 1] !== undefined) {
376
+ limit = Number(args[++i]);
377
+ } else if (a === "--json") {
378
+ json = true;
379
+ } else if (a === "--output" && args[i + 1] !== undefined) {
380
+ out = args[++i] as string;
381
+ }
382
+ }
383
+ const resolved = store ?? defaultStore(process.cwd());
384
+ if (!fs.existsSync(resolved)) {
385
+ process.stderr.write(`conformance:behavior: no transcript store at ${resolved}\n`);
386
+ return 1;
387
+ }
388
+ const report = scanStore(resolved, Number.isFinite(limit) && limit > 0 ? limit : 30);
389
+ if (out) {
390
+ fs.writeFileSync(out, `${JSON.stringify(report, null, 2)}\n`, "utf8");
391
+ }
392
+ // fs.writeSync, not process.stdout.write: `process.exit()` below does not
393
+ // flush an async pipe write, so `--json | jq` reproducibly received a
394
+ // truncated 64 KB document with no error (measured: 85,169 vs 65,536 bytes).
395
+ fs.writeSync(1, json ? `${JSON.stringify(report, null, 2)}\n` : `${render(report)}\n`);
396
+ return 0;
397
+ }
398
+
399
+ declare const __AGENT_CONFIG_BUNDLE__: boolean | undefined;
400
+ function _isCliEntry(): boolean {
401
+ if (typeof __AGENT_CONFIG_BUNDLE__ !== "undefined" && __AGENT_CONFIG_BUNDLE__) {
402
+ return false;
403
+ }
404
+ if (process.argv[1] === undefined) {
405
+ return false;
406
+ }
407
+ const argvUrl = pathToFileURL(path.resolve(process.argv[1])).href;
408
+ if (import.meta.url === argvUrl) {
409
+ return true;
410
+ }
411
+ try {
412
+ const here = fs.realpathSync(fileURLToPath(import.meta.url));
413
+ const argv = fs.realpathSync(path.resolve(process.argv[1]));
414
+ return here === argv;
415
+ } catch {
416
+ return false;
417
+ }
418
+ }
419
+
420
+ if (_isCliEntry()) {
421
+ process.exit(main());
422
+ }
@@ -548,16 +548,38 @@ function reviewerPrompt(args: {
548
548
  ].join('\n');
549
549
  }
550
550
 
551
+ /**
552
+ * The findings skeleton, carrying `prompt_hash`.
553
+ *
554
+ * The contract's most dangerous residual is the PROMPT channel (§ 5): the
555
+ * dispatcher fixes the reviewer's inputs but not the instructions wrapped around
556
+ * them, so "a hash-verified artefact is not evidence of an unbiased review" — its
557
+ * recorded case zero is an honest-null whose prompt stated prior rounds were
558
+ * clean, where a later unsteered round on the identical scope found seven
559
+ * defects.
560
+ *
561
+ * Writing the hash of the prompt THIS dispatcher built makes the channel
562
+ * attributable: the artefact now records which prompt produced it, so a review
563
+ * run against a different prompt is distinguishable from one run against the
564
+ * dispatcher's. `--verify` re-derives it from the same inputs and compares.
565
+ *
566
+ * It does not CLOSE the residual and is not sold as doing so: the same host that
567
+ * authors a steered prompt could hash the steered text. The gain is an
568
+ * attributable record where there was none, which is what turns the audit trail
569
+ * (§ 2.7, round records are never edited in place) into something a contradicting
570
+ * later round can be checked against.
571
+ */
551
572
  function findingsSkeleton(args: {
552
573
  slug: string;
553
574
  headSha: string;
554
575
  scopeHash: string;
555
576
  reviewedDate: string;
556
577
  manifest: string;
578
+ promptHash: string;
557
579
  }): string {
558
580
  return [
559
581
  `# Findings: ${args.slug}`,
560
- `<!-- completion-review: v1 | reviewed: ${args.reviewedDate} | scope: ${args.scopeHash} | diff: ${args.headSha} | reviewer: r2-fresh-subagent-${args.slug} -->`,
582
+ `<!-- completion-review: v1 | reviewed: ${args.reviewedDate} | scope: ${args.scopeHash} | diff: ${args.headSha} | reviewer: r2-fresh-subagent-${args.slug} | prompt_hash: ${args.promptHash} -->`,
561
583
  '',
562
584
  args.manifest,
563
585
  '',
@@ -890,7 +912,14 @@ function runDispatch(args: Args): number {
890
912
  acHash: hashes.ac_hash,
891
913
  dispatched,
892
914
  });
893
- const skeleton = findingsSkeleton({ slug, headSha, scopeHash: hashes.scope_hash, reviewedDate, manifest });
915
+ const skeleton = findingsSkeleton({
916
+ slug,
917
+ headSha,
918
+ scopeHash: hashes.scope_hash,
919
+ reviewedDate,
920
+ manifest,
921
+ promptHash: sha256(promptText),
922
+ });
894
923
 
895
924
  fs.mkdirSync(inputDirAbs, { recursive: true });
896
925
  fs.writeFileSync(path.join(inputDirAbs, 'diff.patch'), scopeDiffText, 'utf-8');