@event4u/agent-config 9.25.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 (80) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/CHANGELOG.md +87 -0
  3. package/README.md +1 -1
  4. package/dist/agent-src/commands/analyze/conformance.md +2 -2
  5. package/dist/agent-src/commands/review/changes.md +6 -5
  6. package/dist/agent-src/commands/roadmap/next.md +194 -0
  7. package/dist/agent-src/commands/roadmap.md +12 -4
  8. package/dist/agent-src/contexts/execution/subagent-routing.md +4 -1
  9. package/dist/agent-src/rules/delegation-policy.md +3 -2
  10. package/dist/agent-src/rules/design-fidelity.md +17 -1
  11. package/dist/agent-src/rules/design-review-after-ui-write.md +116 -0
  12. package/dist/agent-src/rules/evaluator-independence.md +9 -3
  13. package/dist/agent-src/rules/session-canary.md +2 -0
  14. package/dist/agent-src/rules/settings-ask-protocol.md +176 -0
  15. package/dist/agent-src/rules/token-budget-discipline.md +6 -3
  16. package/dist/agent-src/rules/ui-audit-gate.md +3 -0
  17. package/dist/agent-src/rules/user-interaction.md +3 -2
  18. package/dist/agent-src/skills/recursive-verification/SKILL.md +13 -0
  19. package/dist/agent-src/templates/agents/agent-project-settings.example.yml +1 -1
  20. package/dist/cli-delegate/cmd_route_audit.js +4 -1
  21. package/dist/cli-delegate/cmd_route_explain.js +4 -1
  22. package/dist/discovery/deprecation-report.md +1 -1
  23. package/dist/discovery/discovery-manifest.json +94 -23
  24. package/dist/discovery/discovery-manifest.json.sha256 +1 -1
  25. package/dist/discovery/discovery-manifest.summary.md +4 -4
  26. package/dist/discovery/orphan-report.md +1 -1
  27. package/dist/discovery/packs.json +11 -8
  28. package/dist/discovery/trust-report.md +3 -3
  29. package/dist/discovery/workspaces.json +9 -6
  30. package/dist/hooks/dispatch.js +355 -63
  31. package/dist/mcp/registry-manifest.json +3 -3
  32. package/dist/mcp/server.json +2 -2
  33. package/dist/router.json +1 -1
  34. package/dist/server/nicknameResolver.js +116 -0
  35. package/dist/server/nicknameResolver.js.map +1 -0
  36. package/dist/server/routes/ping.js +29 -11
  37. package/dist/server/routes/ping.js.map +1 -1
  38. package/dist/shared/interactiveContext.js +73 -0
  39. package/dist/shared/interactiveContext.js.map +1 -0
  40. package/dist/shared/nicknamePrefill.js +59 -0
  41. package/dist/shared/nicknamePrefill.js.map +1 -0
  42. package/dist/shared/settingsAsks.js +128 -0
  43. package/dist/shared/settingsAsks.js.map +1 -0
  44. package/dist/shared/settingsConsent.js +93 -0
  45. package/dist/shared/settingsConsent.js.map +1 -0
  46. package/package.json +1 -1
  47. package/src/config/agents-paths.yml +15 -0
  48. package/src/config/gate-coverage.yml +29 -1
  49. package/src/config/gate-violation-baselines.json +13 -7
  50. package/src/scripts/_cli/cmd_route_audit.ts +4 -1
  51. package/src/scripts/_cli/cmd_route_explain.ts +9 -1
  52. package/src/scripts/ai-video/stitch.sh +27 -9
  53. package/src/scripts/before_complete_hook.ts +23 -4
  54. package/src/scripts/bench_ab_v2_run.ts +272 -16
  55. package/src/scripts/bench_ab_v2_stats.ts +156 -3
  56. package/src/scripts/check_completion_review.ts +80 -3
  57. package/src/scripts/check_corpus_staleness.ts +692 -0
  58. package/src/scripts/check_gate_coverage.ts +67 -0
  59. package/src/scripts/check_hook_bundle_freshness.ts +183 -0
  60. package/src/scripts/conformance_scan.ts +47 -17
  61. package/src/scripts/dispatch_r2_reviewer.ts +31 -2
  62. package/src/scripts/git_authorization_hook.ts +75 -10
  63. package/src/scripts/hook_manifest.yaml +19 -0
  64. package/src/scripts/hooks/block_no_verify.ts +3 -1
  65. package/src/scripts/hooks/block_unauthorized_git.ts +133 -16
  66. package/src/scripts/hooks/design_slop_hook.ts +13 -1
  67. package/src/scripts/hooks/dispatch_hook.ts +97 -2
  68. package/src/scripts/hooks/evidence_independence.ts +40 -6
  69. package/src/scripts/language_mirror_hook.ts +200 -11
  70. package/src/scripts/lint_abstraction_thresholds.ts +41 -8
  71. package/src/scripts/lint_agents_layout.ts +2 -0
  72. package/src/scripts/lint_hook_manifest.ts +29 -0
  73. package/src/scripts/memory_learn_hook.ts +109 -6
  74. package/src/scripts/pr_url_reminder_hook.ts +17 -2
  75. package/src/scripts/release.ts +38 -1
  76. package/src/scripts/schemas/review-findings.schema.json +99 -0
  77. package/src/shared/interactiveContext.ts +98 -0
  78. package/src/shared/nicknamePrefill.ts +96 -0
  79. package/src/shared/settingsAsks.ts +176 -0
  80. package/src/shared/settingsConsent.ts +136 -0
@@ -363,6 +363,7 @@ export function main(argv: readonly string[]): number {
363
363
  );
364
364
  }
365
365
  const hardening = report_hardening_ratchet();
366
+ const selfTest = report_self_test_ratchet();
366
367
  if (failed.length > 0) {
367
368
  process.stdout.write(`❌ ${String(failed.length)} gate(s) failed the coverage floor.\n`);
368
369
  return 1;
@@ -370,6 +371,9 @@ export function main(argv: readonly string[]): number {
370
371
  if (hardening !== 0) {
371
372
  return hardening;
372
373
  }
374
+ if (selfTest !== 0) {
375
+ return selfTest;
376
+ }
373
377
  process.stdout.write('✅ every enforced gate cleared its coverage floor.\n');
374
378
  return 0;
375
379
  }
@@ -400,6 +404,69 @@ export function report_hardening_ratchet(): number {
400
404
  return verdict.ok ? 0 : 1;
401
405
  }
402
406
 
407
+ /**
408
+ * Registered gates that carry no `--self-test` and no exemption — the count
409
+ * that must not RISE.
410
+ *
411
+ * `_lib/gate_self_test.ts` proves a gate DISCRIMINATES: it drives the real
412
+ * binary through cases that must reject and cases that must accept, with a
413
+ * `minRejectCases` floor so an all-accepting suite cannot satisfy it. A gate
414
+ * that emits an enforced `scanned:` count has proven it read something; only a
415
+ * self-test proves the reading changes the verdict.
416
+ *
417
+ * ## Why this is a NON-ADOPTER count and not an adoption column
418
+ *
419
+ * The roadmap item asked for adoption as a column in `gate-coverage.yml`,
420
+ * ratcheted upward. That is the exact shape `report_hardening_ratchet` above
421
+ * rejects in this same file — a coverage-percentage ratchet "tracks how far the
422
+ * fix has spread and can never regress, so it grades the solution instead of
423
+ * the problem". Inverting it fixes that: the number below can regress (a new
424
+ * registered gate written without a self-test raises it), and the target is 0.
425
+ * That is also the shape the sibling adoption ratchet already uses —
426
+ * `check_gate_completeness`'s ledger baseline is a shrink-only count of
427
+ * non-adopters, not a rising percentage.
428
+ *
429
+ * Population is the MANIFEST, not every script under `src/scripts`: a self-test
430
+ * is a claim about a gate CI runs against a floor. Padding the manifest to move
431
+ * this number would add a floor per row, which is the opposite of gaming.
432
+ *
433
+ * Exemption marker: `// self-test-exempt: <reason>` with a non-empty reason. A
434
+ * bare marker does not count — same discipline as `// no-index:` and
435
+ * `// ledger-exempt:`.
436
+ */
437
+ export function list_self_test_non_adopters(
438
+ dir = path.join(REPO_ROOT, 'src/scripts'),
439
+ ids: ReadonlySet<string> = enforced_manifest_ids(),
440
+ ): string[] {
441
+ const out: string[] = [];
442
+ for (const id of [...ids].sort()) {
443
+ let src: string;
444
+ try {
445
+ src = fs.readFileSync(path.join(dir, `${id}.ts`), 'utf8');
446
+ } catch {
447
+ // A registered id with no readable script cannot prove discrimination.
448
+ out.push(id);
449
+ continue;
450
+ }
451
+ const adopts = /_lib\/gate_self_test\.js/.test(src);
452
+ const exempt = /\/\/\s*self-test-exempt:\s*\S/.test(src);
453
+ if (!adopts && !exempt) out.push(id);
454
+ }
455
+ return out;
456
+ }
457
+
458
+ /** Ratchet wrapper for {@link list_self_test_non_adopters}. */
459
+ export function report_self_test_ratchet(): number {
460
+ const missing = list_self_test_non_adopters();
461
+ const verdict = checkRatchet({
462
+ gate: 'gate-self-test:registered-non-adopters',
463
+ actual: missing.length,
464
+ repoRoot: REPO_ROOT,
465
+ });
466
+ process.stdout.write(` ${verdict.ok ? '✅' : '❌'} ${verdict.message}\n`);
467
+ return verdict.ok ? 0 : 1;
468
+ }
469
+
403
470
  /**
404
471
  * Manifest ids whose `scanned:` line is actually ENFORCED against a floor.
405
472
  *
@@ -0,0 +1,183 @@
1
+ #!/usr/bin/env tsx
2
+ /**
3
+ * Fail when the self-hosted hook bundle is older than the hook sources it
4
+ * bundles — i.e. when this repo's own agent is running a hook fix that has
5
+ * not been built.
6
+ *
7
+ * WHY THIS EXISTS — measured, not hypothetical.
8
+ *
9
+ * The Claude hook command resolves its dispatcher in two steps: the installed
10
+ * package copy (`node_modules/@event4u/agent-config/dist/hooks/dispatch.js`)
11
+ * if present, else this repo's OWN `dist/hooks/dispatch.js`. In the
12
+ * agent-config repo the second branch always wins — that is the dogfooding
13
+ * path, and it is the only place these hooks are exercised before release.
14
+ *
15
+ * `dist/hooks/dispatch.js` is UNTRACKED (a build artifact) and is produced
16
+ * only by `npm run build:hooks`, which no Taskfile target, no `task ci`
17
+ * target, and no workflow invokes. `prepack` runs the full build, so a
18
+ * PUBLISHED package is always fresh and consumers are unaffected. The gap is
19
+ * local and it is exactly where the testing happens.
20
+ *
21
+ * Consequence, observed on 2026-08-06: the conformance round-2 PR (#1195)
22
+ * merged four hook fixes — among them the language-mirror pin — while the
23
+ * running bundle was from 07:27 that morning, before any of them existed.
24
+ * Ten hook sources were newer than the bundle. In the very session that
25
+ * shipped the language fix, the scanner counted four German-prompt /
26
+ * English-reply violations: the fix was merged, correct, and not loaded.
27
+ *
28
+ * A hook fix that cannot reach the agent that wrote it is indistinguishable
29
+ * from no fix, and nothing said so. This gate says so.
30
+ *
31
+ * WHAT IT DOES NOT DO: it never builds. Building into a live hook path is a
32
+ * documented hazard in this repo (an esbuild run that overwrites the
33
+ * dispatcher a running hook is executing can wedge the tool loop), so the
34
+ * remedy is printed for a human to run deliberately.
35
+ *
36
+ * Exit codes: 0 = fresh, or no self-hosted bundle to check · 1 = stale.
37
+ */
38
+ import * as fs from "node:fs";
39
+ import * as path from "node:path";
40
+ import { fileURLToPath } from "node:url";
41
+
42
+ import { parse as parseYaml } from "yaml";
43
+
44
+ import { reportScanned } from "./_lib/scan_scope.js";
45
+
46
+ const HERE = path.dirname(fileURLToPath(import.meta.url));
47
+ const REPO_ROOT = path.resolve(HERE, "..", "..");
48
+ const BUNDLE = path.join("dist", "hooks", "dispatch.js");
49
+ const MANIFEST = path.join("src", "scripts", "hook_manifest.yaml");
50
+ const HOOK_DIR = path.join("src", "scripts", "hooks");
51
+ const REMEDY = "npm run build:hooks";
52
+
53
+ /** Every source whose edit changes the bundle's behaviour. */
54
+ export function bundledSources(root: string): string[] {
55
+ const out = new Set<string>();
56
+
57
+ const manifestAbs = path.join(root, MANIFEST);
58
+ if (fs.existsSync(manifestAbs)) {
59
+ out.add(MANIFEST);
60
+ try {
61
+ const doc = parseYaml(fs.readFileSync(manifestAbs, "utf-8")) as {
62
+ concerns?: Record<string, { script?: string }>;
63
+ };
64
+ for (const def of Object.values(doc?.concerns ?? {})) {
65
+ const s = def?.script;
66
+ // A concern script is bundled wherever it lives, not only under hooks/.
67
+ if (typeof s === "string" && s.endsWith(".ts")) out.add(s);
68
+ }
69
+ } catch {
70
+ // A malformed manifest is another gate's problem; still check the dir.
71
+ }
72
+ }
73
+
74
+ const hookDirAbs = path.join(root, HOOK_DIR);
75
+ if (fs.existsSync(hookDirAbs)) {
76
+ for (const e of fs.readdirSync(hookDirAbs, { withFileTypes: true })) {
77
+ if (e.isFile() && e.name.endsWith(".ts")) out.add(path.join(HOOK_DIR, e.name));
78
+ }
79
+ }
80
+
81
+ return [...out].filter((rel) => fs.existsSync(path.join(root, rel))).sort();
82
+ }
83
+
84
+ export interface Result {
85
+ /** No self-hosted bundle → the installed package copy is authoritative. */
86
+ skipped: boolean;
87
+ bundleMtimeMs: number;
88
+ /** Sources strictly newer than the bundle, newest first. */
89
+ stale: { file: string; mtimeMs: number }[];
90
+ checked: number;
91
+ }
92
+
93
+ export function check(root: string): Result {
94
+ const bundleAbs = path.join(root, BUNDLE);
95
+ if (!fs.existsSync(bundleAbs)) {
96
+ return { skipped: true, bundleMtimeMs: 0, stale: [], checked: 0 };
97
+ }
98
+ const bundleMtimeMs = fs.statSync(bundleAbs).mtimeMs;
99
+ const sources = bundledSources(root);
100
+ const stale = sources
101
+ .map((file) => ({ file, mtimeMs: fs.statSync(path.join(root, file)).mtimeMs }))
102
+ .filter((s) => s.mtimeMs > bundleMtimeMs)
103
+ .sort((a, b) => b.mtimeMs - a.mtimeMs);
104
+ return { skipped: false, bundleMtimeMs, stale, checked: sources.length };
105
+ }
106
+
107
+ function stamp(ms: number): string {
108
+ return new Date(ms).toISOString().replace("T", " ").slice(0, 16);
109
+ }
110
+
111
+ export function main(argv: string[] = process.argv.slice(2)): number {
112
+ const root = REPO_ROOT;
113
+ const quiet = argv.includes("--quiet");
114
+ const r = check(root);
115
+
116
+ // Scope hardening: publish the count and refuse a silent zero. The one
117
+ // legitimate zero is the absent self-hosted bundle — an optional input, and
118
+ // the reason survives the manifest's own test ("if the scan root were
119
+ // deleted, would this still make sense?"): with no bundle there is no stale
120
+ // dispatcher to catch, which is a correct verdict rather than blindness.
121
+ reportScanned({
122
+ gate: "check_hook_bundle_freshness",
123
+ scanned: r.checked,
124
+ units: "bundled hook source(s)",
125
+ roots: [BUNDLE, HOOK_DIR, MANIFEST],
126
+ ...(r.skipped
127
+ ? {
128
+ allowEmpty:
129
+ "OPTIONAL_INPUT: no self-hosted dist/hooks/dispatch.js — this repo is not " +
130
+ "running its own bundle (fresh checkout / CI), so there is nothing to compare against.",
131
+ }
132
+ : {}),
133
+ });
134
+
135
+ // A gate that scans nothing must SAY it scanned nothing — a silent green
136
+ // here is indistinguishable from a pass.
137
+ if (r.skipped) {
138
+ if (!quiet) {
139
+ process.stdout.write(
140
+ `✅ OK hook bundle: no self-hosted ${BUNDLE} — the installed package copy is in use, nothing to check\n`,
141
+ );
142
+ }
143
+ return 0;
144
+ }
145
+
146
+ if (r.stale.length === 0) {
147
+ if (!quiet) {
148
+ process.stdout.write(
149
+ `✅ OK hook bundle: fresh (built ${stamp(r.bundleMtimeMs)}, ${String(r.checked)} bundled source(s) checked)\n`,
150
+ );
151
+ }
152
+ return 0;
153
+ }
154
+
155
+ console.error(
156
+ `❌ hook bundle is STALE — the running dispatcher predates ${r.stale.length} of ${r.checked} bundled source(s).`,
157
+ );
158
+ console.error(` ${BUNDLE} built ${stamp(r.bundleMtimeMs)}; newer sources:`);
159
+ for (const s of r.stale.slice(0, 10)) {
160
+ console.error(` ${stamp(s.mtimeMs)} ${s.file}`);
161
+ }
162
+ if (r.stale.length > 10) console.error(` … and ${r.stale.length - 10} more`);
163
+ console.error("");
164
+ console.error(` Your hooks are running the OLD code. Rebuild: ${REMEDY}`);
165
+ console.error(
166
+ " (This gate never builds for you — writing into a live hook path can wedge the tool loop.)",
167
+ );
168
+ return 1;
169
+ }
170
+
171
+ const invokedDirectly = ((): boolean => {
172
+ const entry = process.argv[1];
173
+ if (entry === undefined) return false;
174
+ try {
175
+ return fs.realpathSync(path.resolve(entry)) === fs.realpathSync(fileURLToPath(import.meta.url));
176
+ } catch {
177
+ return false;
178
+ }
179
+ })();
180
+
181
+ if (invokedDirectly) {
182
+ process.exit(main());
183
+ }
@@ -139,6 +139,15 @@ export function isInjectedBody(text: string): boolean {
139
139
  return text.length > 2500 && classify(text).language === "en";
140
140
  }
141
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
+
142
151
  /** Rough English-opener test for the language check (first prose line only). */
143
152
  const EN_OPENER =
144
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;
@@ -152,6 +161,7 @@ export function scanSession(sessionId: string, lines: string[]): SessionReport {
152
161
  };
153
162
 
154
163
  let pinned: "de" | "en" | null = null;
164
+ let pendingPoll: { at: string; cmd: string } | null = null;
155
165
  let authorized = new Set<GitOp>();
156
166
  let sawPending = false;
157
167
  let evaluationsThisTurn = 0;
@@ -167,6 +177,25 @@ export function scanSession(sessionId: string, lines: string[]): SessionReport {
167
177
  continue;
168
178
  }
169
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
+
170
199
  const ut = userText(entry);
171
200
  if (ut !== null) {
172
201
  if (isInjectedBody(ut)) {
@@ -181,6 +210,7 @@ export function scanSession(sessionId: string, lines: string[]): SessionReport {
181
210
  authorized = new Set(classifyAuthorization(ut).authorized);
182
211
  sawPending = false;
183
212
  evaluationsThisTurn = 0;
213
+ pendingPoll = null;
184
214
  continue;
185
215
  }
186
216
 
@@ -194,6 +224,9 @@ export function scanSession(sessionId: string, lines: string[]): SessionReport {
194
224
  report.assistant_turns += 1;
195
225
  if (pinned === "de") {
196
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
+ }
197
230
  const c = classify(first);
198
231
  if (first.length > 12 && (c.language === "en" || EN_OPENER.test(first.trim()))) {
199
232
  report.violations.push({
@@ -220,21 +253,14 @@ export function scanSession(sessionId: string, lines: string[]): SessionReport {
220
253
  });
221
254
  }
222
255
  if (isCiPoll(cmd)) {
223
- // A settle claim needs a prior in-flight observation in the same turn.
224
- // The transcript carries the poll but not always its output, so this
225
- // check only fires on the shape it can actually read.
226
- const out = typeof entry["toolUseResult"] === "string" ? entry["toolUseResult"] : "";
227
- const pending = out ? pendingCount(out) : null;
228
- if (pending !== null && pending > 0) {
229
- sawPending = true;
230
- } else if (out && (isVacuousOutput(out) || (pending === 0 && !sawPending))) {
231
- report.violations.push({
232
- check: "vacuous-evidence",
233
- session: sessionId,
234
- at,
235
- detail: `CI poll read as settled without an in-flight observation: ${out.slice(0, 90)}`,
236
- });
237
- }
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 };
238
264
  }
239
265
  }
240
266
 
@@ -269,7 +295,8 @@ export function scanSession(sessionId: string, lines: string[]): SessionReport {
269
295
 
270
296
  /** Default Claude Code transcript store for a project directory. */
271
297
  export function defaultStore(projectDir: string): string {
272
- const slug = projectDir.replace(/\//g, "-");
298
+ // Claude Code slugs BOTH separators and dots: /Users/x/.claude → -Users-x--claude.
299
+ const slug = projectDir.replace(/[/.]/g, "-");
273
300
  return path.join(process.env["HOME"] ?? "", ".claude", "projects", slug);
274
301
  }
275
302
 
@@ -362,7 +389,10 @@ export function main(argv?: string[]): number {
362
389
  if (out) {
363
390
  fs.writeFileSync(out, `${JSON.stringify(report, null, 2)}\n`, "utf8");
364
391
  }
365
- process.stdout.write(json ? `${JSON.stringify(report, null, 2)}\n` : `${render(report)}\n`);
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`);
366
396
  return 0;
367
397
  }
368
398
 
@@ -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');
@@ -83,16 +83,65 @@ export const ALL_OPS: readonly GitOp[] = [
83
83
  * cost latency on every turn and be unauditable.
84
84
  */
85
85
  const PHRASES: ReadonlyArray<{ op: GitOp; re: RegExp }> = [
86
- { op: "commit", re: /\b(commit(e|et|ten|ted|ting)?|committe|einchecken)\b/i },
87
- { op: "push", re: /\b(push(e|en|ed|ing)?|hochladen|hochschieben|raufschieben)\b/i },
88
- { op: "branch", re: /\b(branch(e|es)?|zweig|feature-branch|erstelle einen branch)\b/i },
89
- { op: "pr-create", re: /\b(pull[- ]request|pr\b|mach(e|en)? (einen|nen) pr|erstelle .{0,20}pr)\b/i },
90
- { op: "pr-merge", re: /\b(merge[nrd]?|mergen|zusammenführen|zusammenfuehren|reinmergen|gemerged)\b/i },
91
- { op: "tag", re: /\b(tag(ge|gen|ged|ging)?|version(iere|ieren)?)\b/i },
92
- { op: "release", re: /\b(release[nd]?|releasen|veröffentlich(e|en|ung)|veroeffentlich(e|en))\b/i },
93
- { op: "publish", re: /\b(publish(e|en|ed|ing)?|npm publish|publiziere[n]?)\b/i },
86
+ { op: "commit", re: /\b(commit(e|et|te|ten)?|committe|einchecken)\b/i },
87
+ { op: "push", re: /\b(push(e|en|t)?|hochladen|hochschieben|raufschieben)\b/i },
88
+ { op: "branch", re: /\b(branch|feature-branch|zweig)\b/i },
89
+ // A creation verb is required — "schau dir den PR an" is not "open a PR".
90
+ {
91
+ op: "pr-create",
92
+ re: /\b(erstell(e|en)?|mach(e|en)?|leg(e|en)?\s+an|(er)?(ö|oe)ffne|open|create|raise|aufmachen)\b[^.\n]{0,30}\b(pr|pull[- ]request)\b|\b(pr|pull[- ]request)\b[^.\n]{0,20}\b(erstellen|aufmachen|anlegen|(er)?(ö|oe)ffnen)\b/i,
93
+ },
94
+ // `merge` as an ACTION, never as the noun in "merge conflict" / "merge commit".
95
+ { op: "pr-merge", re: /\b(merge|merg(e|en|st|t)|zusammenf(ü|ue)hren|reinmergen)\b(?!\s*[- ]?(conflict|konflikt|commit|base|queue|state|status))/i },
96
+ // A tag is an ACTION here — bare "Tag" is the German word for day, and
97
+ // "Version" is an ordinary noun. Both authorized a BLOCK op before this.
98
+ { op: "tag", re: /\b(tagge(n|st)?|tag\s+(setzen|anlegen|erstellen)|git\s+tag|--tags|--follow-tags)\b/i },
99
+ // "die release notes sind falsch" is a noun phrase, not an authorization.
100
+ {
101
+ op: "release",
102
+ re: /\b(releasen?|ver(ö|oe)ffentlich(e|en))\b(?!\s*[- ]?(notes?|branch|candidate|pr\b|datum|date|zweig))/i,
103
+ },
104
+ { op: "publish", re: /\b(publish(e|en)?|publiziere[n]?)\b/i },
94
105
  ];
95
106
 
107
+ /**
108
+ * Lines that are pasted TOOL OUTPUT rather than the user instructing.
109
+ *
110
+ * Round-2 adversarial review: an UNFENCED paste of a `git push … rejected`
111
+ * trace authorized `push` through the prose matcher — i.e. the gate
112
+ * pre-authorized the exact scenario it was built to stop. Fenced pastes were
113
+ * already handled; prose was not.
114
+ */
115
+ const OUTPUT_LINE =
116
+ /^\s*(To\s+\S+|remote:|error:|fatal:|hint:|warning:|!\s|\?\?\s|\s*\^|[-+]{3}\s|@@\s|\$\s|>\s|\d+\s+(pass|fail)|npm ERR!|Error:|Traceback|at\s+\S+:\d+)/i;
117
+
118
+ /**
119
+ * A prompt that ASKS about an operation does not authorize it.
120
+ *
121
+ * `question-not-instruction` states this for the agent; the ledger needs it
122
+ * too. "was macht npm publish eigentlich genau?" authorized a real publish
123
+ * before this check.
124
+ */
125
+ export function isInterrogative(prose: string): boolean {
126
+ const t = prose.trim();
127
+ if (!t) {
128
+ return false;
129
+ }
130
+ const hasImperative =
131
+ /\b(mach|mache|bitte|leg|lege|erstell|erstelle|f(ü|ue)hr|f(ü|ue)hre|setz|setze|starte|los|jetzt|go ahead|do it|ja[,.]?\s|ok[,.]?\s)\b/i.test(
132
+ t,
133
+ );
134
+ if (hasImperative) {
135
+ return false;
136
+ }
137
+ return (
138
+ /\?\s*$/.test(t) ||
139
+ /^(was|wie|warum|wieso|weshalb|wann|wer|welche[rs]?|wo|ist|sind|kann|kannst|k(ö|oe)nnen|soll|sollen|darf|d(ü|ue)rfen|why|what|how|when|which|who|is|are|can|could|should|does|do)\b/i.test(
140
+ t,
141
+ )
142
+ );
143
+ }
144
+
96
145
  /** Executable commands a user may paste, mapped to the op they authorize. */
97
146
  const PASTED_COMMANDS: ReadonlyArray<{ op: GitOp; re: RegExp }> = [
98
147
  { op: "publish", re: /\bnpm\s+publish\b/i },
@@ -155,8 +204,15 @@ export function classifyAuthorization(prompt: string): {
155
204
  const authorized = new Set<GitOp>();
156
205
  const evidence: Record<string, string> = {};
157
206
 
158
- for (const { op, re } of PHRASES) {
159
- const m = re.exec(prose);
207
+ // Drop pasted tool output from the prose before matching (C4), then refuse
208
+ // to read a question as an instruction (C3).
209
+ const instruction = prose
210
+ .split("\n")
211
+ .filter((l) => !OUTPUT_LINE.test(l))
212
+ .join("\n");
213
+
214
+ for (const { op, re } of isInterrogative(instruction) ? [] : PHRASES) {
215
+ const m = re.exec(instruction);
160
216
  if (m) {
161
217
  authorized.add(op);
162
218
  evidence[op] = `prose: "${m[0]}"`;
@@ -164,6 +220,15 @@ export function classifyAuthorization(prompt: string): {
164
220
  }
165
221
 
166
222
  for (const fence of fences) {
223
+ // A fence that also carries tool OUTPUT is a transcript of something that
224
+ // already happened, not a command the user is handing over. Pasting
225
+ // $ git push origin main
226
+ // ! [rejected] …
227
+ // is showing a failure, and reading it as "authorized push" is exactly the
228
+ // implicit-continuation misread this gate exists to stop.
229
+ if (fence.split("\n").some((l) => OUTPUT_LINE.test(l))) {
230
+ continue;
231
+ }
167
232
  for (const line of _commandLines(fence)) {
168
233
  for (const { op, re } of PASTED_COMMANDS) {
169
234
  if (re.test(line)) {
@@ -163,6 +163,25 @@ concerns:
163
163
  args: []
164
164
  fail_closed: false
165
165
  severity: advisory
166
+ # Per-concern tool filter — the dispatcher skips this concern in-process on
167
+ # any other tool. See `_concern_matches_tool` in hooks/dispatch_hook.ts for
168
+ # the semantics (absent = every event, "*" = the same, non-tool events are
169
+ # never filtered) and for why the filter is NOT a host `matcher`.
170
+ #
171
+ # This set is not a guess: the hook's entire branch surface is
172
+ # `name === 'Grep' || name === 'Glob'` and `name === 'Read'`
173
+ # (code_graph_nudge_hook.ts), and a test pins the manifest list against it.
174
+ #
175
+ # Deliberately NOT declared on the three blocking PreToolUse guards
176
+ # (block-no-verify, block-kernel-rule-writes, block-config-weakening): their
177
+ # tool sets span host-specific naming variants (`Bash` / `BashTool` /
178
+ # `launch-process` / `launch_process`, `str-replace-editor` /
179
+ # `str_replace_editor` / `save-file` / …), so a list that misses one variant
180
+ # silently disables a security guard on that host. The concerns already
181
+ # re-read `tool_name` and return early; paying one dispatch to keep a guard
182
+ # correct is the right trade, and the cheap filter is reserved for advisory
183
+ # concerns whose set is provable from their own source.
184
+ tools: [Grep, Glob, Read]
166
185
  # road-to-reachable-code-memory Phase 4 — session_end learning-sidecar
167
186
  # aggregation. Joins the EXISTING session_end concern set (no new platform
168
187
  # wiring). Aggregates agents/memory/intake/*.jsonl through the learning
@@ -339,7 +339,9 @@ function _check_command(cmd: string): [boolean, string] {
339
339
  if (/\bgit\b/.test(cmd)) {
340
340
  return [
341
341
  true,
342
- 'command parse failed (shlex) on a git-containing command — fail-closed (git-history-discipline)',
342
+ 'command parse failed (shlex) on a git-containing command — fail-closed (git-history-discipline). \n'
343
+ + 'Most common cause: an apostrophe or backtick inside a heredoc commit message in the SAME compound command as `git`. \n'
344
+ + 'Fix: write the message to a file and use `git commit -F <file>` — do not retry the heredoc.',
343
345
  ];
344
346
  }
345
347
  return [false, ''];