@event4u/agent-config 9.30.0 → 9.31.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 (62) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/CHANGELOG.md +31 -314
  3. package/dist/agent-src/templates/agents/agent-project-settings.example.yml +1 -1
  4. package/dist/cli-delegate/{chunk-IYTGIG2Y.js → chunk-2WCLVDWC.js} +1 -1
  5. package/dist/cli-delegate/{chunk-66OVWGB5.js → chunk-AG47LAGQ.js} +1 -1
  6. package/dist/cli-delegate/{chunk-MP3JT3IU.js → chunk-DS6BLDMG.js} +1 -1
  7. package/dist/cli-delegate/{chunk-O3CO5BFB.js → chunk-EJITJBJX.js} +12 -1
  8. package/dist/cli-delegate/{chunk-EQY4FLJB.js → chunk-ITIQSGOQ.js} +3 -3
  9. package/dist/cli-delegate/{chunk-LCFJIQ4J.js → chunk-JEJABGKB.js} +110 -6
  10. package/dist/cli-delegate/{chunk-BTJNRUX3.js → chunk-QEEB4LHO.js} +1 -1
  11. package/dist/cli-delegate/{chunk-DDUNNFYX.js → chunk-UVKNIJH2.js} +2 -2
  12. package/dist/cli-delegate/cmd_analyze_session.js +1 -1
  13. package/dist/cli-delegate/cmd_brand_status.js +1 -1
  14. package/dist/cli-delegate/cmd_conformance.js +98 -36
  15. package/dist/cli-delegate/cmd_doctor.js +3 -3
  16. package/dist/cli-delegate/cmd_explain.js +2 -2
  17. package/dist/cli-delegate/cmd_export.js +4 -4
  18. package/dist/cli-delegate/cmd_handoff.js +2 -2
  19. package/dist/cli-delegate/cmd_mcp_available.js +1 -1
  20. package/dist/cli-delegate/cmd_migrate.js +2 -2
  21. package/dist/cli-delegate/cmd_packs_active.js +2 -2
  22. package/dist/cli-delegate/cmd_prune.js +4 -4
  23. package/dist/cli-delegate/cmd_route_audit.js +2 -2
  24. package/dist/cli-delegate/cmd_route_explain.js +1 -1
  25. package/dist/cli-delegate/cmd_session_recycle.js +2 -2
  26. package/dist/cli-delegate/cmd_settings_get.js +2 -55
  27. package/dist/cli-delegate/cmd_settings_migrate.js +4 -4
  28. package/dist/cli-delegate/cmd_settings_set.js +1 -1
  29. package/dist/cli-delegate/cmd_sync.js +4 -4
  30. package/dist/cli-delegate/cmd_uninstall.js +4 -4
  31. package/dist/cli-delegate/cmd_update.js +1 -1
  32. package/dist/cli-delegate/cmd_upgrade.js +1 -1
  33. package/dist/cli-delegate/cmd_validate.js +4 -4
  34. package/dist/cli-delegate/cmd_versions.js +1 -1
  35. package/dist/discovery/deprecation-report.md +1 -1
  36. package/dist/discovery/discovery-manifest.json +1 -1
  37. package/dist/discovery/discovery-manifest.json.sha256 +1 -1
  38. package/dist/discovery/discovery-manifest.summary.md +1 -1
  39. package/dist/discovery/orphan-report.md +1 -1
  40. package/dist/discovery/packs.json +1 -1
  41. package/dist/discovery/trust-report.md +1 -1
  42. package/dist/discovery/workspaces.json +1 -1
  43. package/dist/hooks/dispatch.js +347 -174
  44. package/dist/install/install.mjs +121 -16
  45. package/dist/mcp/registry-manifest.json +1 -1
  46. package/dist/mcp/server.json +2 -2
  47. package/dist/mcp/server.mjs +310 -138
  48. package/dist/scripts/_lib/agent_settings.js +111 -2
  49. package/dist/scripts/_lib/agent_settings.js.map +1 -1
  50. package/dist/server/schemas/settings.js +2 -2
  51. package/dist/server/schemas/settings.js.map +1 -1
  52. package/package.json +1 -1
  53. package/src/config/pack-size-budget.json +3 -2
  54. package/src/scripts/_cli/explain_last/inputs.ts +13 -0
  55. package/src/scripts/_lib/agent_settings.ts +119 -2
  56. package/src/scripts/_lib/carrier_divergence.ts +47 -0
  57. package/src/scripts/hooks/turn_end_gate_hook.ts +296 -88
  58. package/src/scripts/language_mirror_hook.ts +69 -16
  59. package/src/scripts/measure_turn_end_gate.ts +129 -51
  60. package/src/scripts/report_carrier_divergence.ts +72 -24
  61. package/src/scripts/report_conformance_funnel.ts +11 -4
  62. package/src/server/schemas/settings.ts +2 -2
@@ -72,7 +72,7 @@ import fs from "node:fs";
72
72
  import path from "node:path";
73
73
  import { fileURLToPath, pathToFileURL } from "node:url";
74
74
 
75
- import { atomic_write_json } from "./hooks/state_io.js";
75
+ import { atomic_write_json, atomic_write_text, is_replay_mode } from "./hooks/state_io.js";
76
76
  import { readHookStdin } from "./hooks/hook_stdin.js";
77
77
  import { humanAuthoredLead, isSyntheticPrompt } from "./_lib/prompt_shape.js";
78
78
 
@@ -92,7 +92,21 @@ export const STATE_FILE = path.join("agents", "state", "language-mirror.json");
92
92
  * re-emit that follows it (round-5 § 6.1). Its existence — not a counter — is
93
93
  * what bounds the re-injection to ONE per compaction event.
94
94
  */
95
- export const PIN_LOST_MARKER = path.join("agents", "state", "language-mirror.pin-lost");
95
+ export const PIN_LOST_DIR = path.join("agents", "state", "language-mirror.pin-lost");
96
+
97
+ /**
98
+ * Per-SESSION marker path. R2 finding 10: a single shared marker made the
99
+ * mechanism cross-talk between concurrent sessions — the normal shape for this
100
+ * repo's worktree workflow. Session B's next `post_tool_use` consumed the marker
101
+ * session A's compaction had set, so A never got the one re-emit that is the
102
+ * whole point of the mechanism, and B got a pin notice derived from whichever
103
+ * session last wrote the shared state. An empty id degrades to a literal
104
+ * bucket rather than colliding silently.
105
+ */
106
+ export function pinLostMarker(session_id: string): string {
107
+ const safe = (session_id || "unknown-session").replace(/[^A-Za-z0-9._-]/g, "_");
108
+ return path.join(PIN_LOST_DIR, `${safe}.marker`);
109
+ }
96
110
 
97
111
  type JsonValue = string | number | boolean | null | JsonValue[] | { [k: string]: JsonValue };
98
112
  type JsonObject = { [k: string]: JsonValue };
@@ -387,29 +401,68 @@ function _extractPrompt(payload: JsonObject): string {
387
401
  return "";
388
402
  }
389
403
 
390
- /** Record that compaction is about to drop the context copy of the pin. */
391
- export function _setPinLost(consumer_root: string): void {
404
+ /** The envelope's session id, or a literal bucket when the host sends none. */
405
+ function _sessionId(envelope: JsonObject): string {
406
+ const v = envelope["session_id"];
407
+ return typeof v === "string" && v.trim() !== "" ? v : "unknown-session";
408
+ }
409
+
410
+ /**
411
+ * Record that compaction is about to drop the context copy of the pin.
412
+ *
413
+ * R2 finding 14: this used to write with a raw `mkdirSync` + `writeFileSync`,
414
+ * bypassing both conventions the surrounding code follows — the module's own
415
+ * atomic writer, and the `is_replay_mode()` guard that keeps a replayed event
416
+ * from mutating live state. A replayed compaction wrote the marker for real.
417
+ */
418
+ /**
419
+ * The pre-R2 layout wrote a single FILE at the path that is now a DIRECTORY.
420
+ * A consumer upgrading with a leftover legacy marker — compaction happened, no
421
+ * `post_tool_use` followed — hits `EEXIST` on the mkdir and `ENOTDIR` on the
422
+ * write (reproduced against Node), which the catch below swallows as
423
+ * "observability only". The failure would then be silent AND permanent for that
424
+ * root: `existsSync` always false, so the mechanism is dead with no diagnostic.
425
+ * One unlink at the moment of collision, and only when the path is a file.
426
+ */
427
+ function _migrateLegacyMarker(consumer_root: string): void {
428
+ const legacy = path.join(consumer_root, PIN_LOST_DIR);
429
+ try {
430
+ if (fs.statSync(legacy).isFile()) {
431
+ fs.rmSync(legacy, { force: true });
432
+ }
433
+ } catch {
434
+ // Absent, already a directory, or unreadable — nothing to migrate.
435
+ }
436
+ }
437
+
438
+ export function _setPinLost(consumer_root: string, session_id: string): void {
439
+ if (is_replay_mode()) return;
440
+ _migrateLegacyMarker(consumer_root);
392
441
  try {
393
- const target = path.join(consumer_root, PIN_LOST_MARKER);
394
- fs.mkdirSync(path.dirname(target), { recursive: true });
395
- fs.writeFileSync(target, new Date().toISOString());
442
+ atomic_write_text(path.join(consumer_root, pinLostMarker(session_id)), new Date().toISOString());
396
443
  } catch {
397
444
  // Observability only — a failed marker write costs one un-restored pin,
398
445
  // never a broken turn.
399
446
  }
400
447
  }
401
448
 
402
- export function _pinLost(consumer_root: string): boolean {
449
+ export function _pinLost(consumer_root: string, session_id: string): boolean {
403
450
  try {
404
- return fs.existsSync(path.join(consumer_root, PIN_LOST_MARKER));
451
+ return fs.existsSync(path.join(consumer_root, pinLostMarker(session_id)));
405
452
  } catch {
406
453
  return false;
407
454
  }
408
455
  }
409
456
 
410
- function _clearPinLost(consumer_root: string): void {
457
+ function _clearPinLost(consumer_root: string, session_id: string): void {
458
+ // R2 round 2, finding 8: the replay guard covered only the WRITE. A replayed
459
+ // `post_tool_use` therefore deleted a live marker it was forbidden to create,
460
+ // and the real session lost the single re-emit this whole mechanism exists to
461
+ // deliver — replay destroying live state while blocked from writing it, which
462
+ // is worse than either direction alone.
463
+ if (is_replay_mode()) return;
411
464
  try {
412
- fs.rmSync(path.join(consumer_root, PIN_LOST_MARKER), { force: true });
465
+ fs.rmSync(path.join(consumer_root, pinLostMarker(session_id)), { force: true });
413
466
  } catch {
414
467
  // If it cannot be cleared the next tool call re-emits once more. One
415
468
  // duplicate line is the failure mode; a loop is not, because the marker
@@ -422,8 +475,8 @@ function _clearPinLost(consumer_root: string): void {
422
475
  * and only when a pin actually exists — an absent or undetermined pin is not
423
476
  * an obligation, and inventing one here would be worse than the gap.
424
477
  */
425
- export function _reEmitAfterCompaction(consumer_root: string): number {
426
- if (!_pinLost(consumer_root)) {
478
+ export function _reEmitAfterCompaction(consumer_root: string, session_id: string): number {
479
+ if (!_pinLost(consumer_root, session_id)) {
427
480
  return EXIT_ALLOW;
428
481
  }
429
482
  const previous = _loadState(path.join(consumer_root, STATE_FILE));
@@ -431,7 +484,7 @@ export function _reEmitAfterCompaction(consumer_root: string): number {
431
484
  const source: PinSource = previous.source === "system-locale" ? "system-locale" : "prompt";
432
485
  // Clear FIRST: if the write below throws, the marker must not survive to
433
486
  // re-fire on every subsequent tool call — that is the shape § 6.2 refuses.
434
- _clearPinLost(consumer_root);
487
+ _clearPinLost(consumer_root, session_id);
435
488
  if (language !== "de" && language !== "en") {
436
489
  return EXIT_ALLOW;
437
490
  }
@@ -479,11 +532,11 @@ export function run(
479
532
  // and this marker is the entire difference between the two.
480
533
  const event = typeof envelope["event"] === "string" ? envelope["event"] : "";
481
534
  if (event === "pre_compact") {
482
- _setPinLost(options.consumer_root);
535
+ _setPinLost(options.consumer_root, _sessionId(envelope));
483
536
  return EXIT_ALLOW;
484
537
  }
485
538
  if (event === "post_tool_use") {
486
- return _reEmitAfterCompaction(options.consumer_root);
539
+ return _reEmitAfterCompaction(options.consumer_root, _sessionId(envelope));
487
540
  }
488
541
 
489
542
  const prompt = _extractPrompt(payload);
@@ -11,37 +11,61 @@
11
11
  *
12
12
  * What it measures, and what it deliberately does NOT:
13
13
  *
14
- * · FIRE RATE of each detector over every assistant turn in the window.
15
- * · For detector B, the CROSS-TAB against `conformance_scan`'s own
16
- * independent language check. That scanner reads only the first prose
17
- * line and this detector reads the whole stripped reply, so the two are
18
- * related instruments, not the same one — which is exactly what makes
19
- * the disagreement cells informative.
14
+ * · FIRE RATE of each detector PER TURN, over the last assistant text of the
15
+ * turn — the population the shipped gate inspects. R2 finding 1: the first
16
+ * version scored every assistant ENTRY and published the result as a
17
+ * per-turn rate, which inflated the population ~8.6x and scored a
18
+ * closing-paragraph detector over mid-turn prose.
19
+ * · The independent scanner's own language total, printed for CONTEXT only.
20
+ * It is not an agreement figure and no overlap is claimed from it: the two
21
+ * instruments read different spans (its first prose line; this one strips
22
+ * code/quotes/paths from the reply and then scores lead-first via the same
23
+ * classifier), and this script does no per-turn matching between them.
24
+ * R2 finding 7 removed the per-session min/max arithmetic that used to
25
+ * report disjoint findings of equal count as full agreement.
20
26
  * · It does NOT compute recall against the 20 promissory occurrences the
21
27
  * round-5 audit reported. Those came from subagent transcript READING;
22
28
  * `conformance_scan.ts` states in its own header that promissory
23
29
  * closings are "deliberately NOT scanned", so there is no machine-
24
30
  * readable ground truth to score against. Claiming a recall figure here
25
31
  * would invent one.
32
+ * · KNOWN RESIDUAL, R2 finding 8, not fixed here: this script re-derives the
33
+ * language pin with `classify` per user entry, while the gate reads the pin
34
+ * `language_mirror_hook` persisted — which also applies a system-locale
35
+ * fallback and a keep-previous rule. Where those diverge, a turn can move
36
+ * between "pinned de", "pinned en" and "no obligation", so detector B's
37
+ * rate here is close to but not identical with the gate's. Reaching parity
38
+ * means reusing `nextState`, which is a larger change than a measurement
39
+ * correction and would itself need re-measuring.
26
40
  *
27
41
  * Usage:
28
42
  * ./scripts-run src/scripts/measure_turn_end_gate --store <dir> [--limit N]
29
43
  */
30
44
  import * as fs from 'node:fs';
31
45
  import * as path from 'node:path';
46
+ import { fileURLToPath, pathToFileURL } from 'node:url';
32
47
 
33
- import { classify } from './language_mirror_hook.js';
34
- import { assistantText, isInjectedBody, scanSession, userText } from './conformance_scan.js';
48
+ import { classify, isSyntheticPrompt } from './language_mirror_hook.js';
49
+ import { assistantText, scanSession, userText } from './conformance_scan.js';
35
50
  import { detectLanguage, detectPromissory } from './hooks/turn_end_gate_hook.js';
36
51
 
37
52
  interface Counts {
38
- assistant_turns: number;
53
+ /**
54
+ * TURNS, not entries. R2 finding 1: the first version incremented a counter
55
+ * named `assistant_turns` on every assistant entry and ran both detectors on
56
+ * each one, while the gate only ever evaluates the LAST assistant text of a
57
+ * turn. A turn with k prose entries contributed k samples to the instrument
58
+ * and 1 to the gate, so the published "18.0% of turns" was a per-ENTRY rate
59
+ * wearing a per-turn label — and detector A, a closing-paragraph detector by
60
+ * construction, was being scored over mid-turn prose.
61
+ */
62
+ turns: number;
63
+ /** Assistant entries seen — kept only to show the ratio the old bug hid. */
64
+ assistant_entries: number;
39
65
  promissory_fires: number;
40
66
  language_fires: number;
41
- /** Detector B fired AND the scanner also flagged that session-turn. */
42
- language_agree: number;
43
- /** Detector B fired where the scanner saw nothing — candidate false positives. */
44
- language_only: number;
67
+ /** The independent scanner's own per-session language total, for context. */
68
+ scanner_language: number;
45
69
  sessions: number;
46
70
  }
47
71
 
@@ -68,11 +92,11 @@ function sessionFiles(store: string, limit: number): string[] {
68
92
 
69
93
  export function measure(store: string, limit: number): Counts {
70
94
  const c: Counts = {
71
- assistant_turns: 0,
95
+ turns: 0,
96
+ assistant_entries: 0,
72
97
  promissory_fires: 0,
73
98
  language_fires: 0,
74
- language_agree: 0,
75
- language_only: 0,
99
+ scanner_language: 0,
76
100
  sessions: 0,
77
101
  };
78
102
 
@@ -86,20 +110,36 @@ export function measure(store: string, limit: number): Counts {
86
110
  c.sessions += 1;
87
111
  const sessionId = path.basename(file).slice(0, 8);
88
112
 
89
- // The scanner's independent verdict for this session, keyed by the
90
- // timestamp-free count of language violations. Used only as a
91
- // cross-tab total, never as ground truth for this detector.
92
- let scannerLanguage = 0;
113
+ // The independent scanner's own language total for this session,
114
+ // reported side by side and NOT as agreement — see the header for why
115
+ // no overlap is derivable from two totals (R2 finding 7).
116
+ //
117
+ // A throw is surfaced, not swallowed to zero: swallowing silently
118
+ // reclassified a whole session's fires as scanner-disagreements.
93
119
  try {
94
- scannerLanguage = scanSession(sessionId, lines).violations.filter(
120
+ c.scanner_language += scanSession(sessionId, lines).violations.filter(
95
121
  (v) => v.check === 'language-pin',
96
122
  ).length;
97
- } catch {
98
- scannerLanguage = 0;
123
+ } catch (err) {
124
+ process.stderr.write(
125
+ `measure-turn-end-gate: scanSession failed for ${sessionId} — excluded from the scanner total: ${String(err)}\n`,
126
+ );
99
127
  }
100
128
 
101
129
  let pinned: 'de' | 'en' | 'und' = 'und';
102
- let myLanguage = 0;
130
+ // The LAST assistant text since the last genuine user prompt. This is
131
+ // the population the gate inspects; anything else is a different
132
+ // measurement wearing the same label (R2 finding 1).
133
+ let pendingReply: string | null = null;
134
+ let pendingPin: 'de' | 'en' | 'und' = 'und';
135
+
136
+ const scoreTurn = (): void => {
137
+ if (pendingReply === null) return;
138
+ c.turns += 1;
139
+ if (detectPromissory(pendingReply) !== null) c.promissory_fires += 1;
140
+ if (detectLanguage(pendingReply, pendingPin) !== null) c.language_fires += 1;
141
+ pendingReply = null;
142
+ };
103
143
 
104
144
  for (const raw of lines) {
105
145
  let entry: Record<string, unknown>;
@@ -111,10 +151,14 @@ export function measure(store: string, limit: number): Counts {
111
151
 
112
152
  const u = userText(entry);
113
153
  if (u !== null) {
114
- // A background-task notification occupies the user role but is
115
- // not a chat message — the exact shape that made the language
116
- // defect invisible. It must never move the pin.
117
- if (!isInjectedBody(u)) {
154
+ // `isSyntheticPrompt`, the SAME filter the gate uses for the
155
+ // ordinal — not `isInjectedBody`. R2 round 2, finding 10: the
156
+ // two filters classify differently, and any entry they disagree
157
+ // about shifts this instrument's turn boundary away from the
158
+ // gate's own. Population parity is the entire point of this
159
+ // correction, so the filter has to be the same one.
160
+ if (!isSyntheticPrompt(u)) {
161
+ scoreTurn(); // the previous turn ends here
118
162
  const verdict = classify(u);
119
163
  if (verdict.language !== 'und') pinned = verdict.language;
120
164
  }
@@ -123,39 +167,38 @@ export function measure(store: string, limit: number): Counts {
123
167
 
124
168
  const a = assistantText(entry);
125
169
  if (a === null || a.trim() === '') continue;
126
- c.assistant_turns += 1;
127
-
128
- if (detectPromissory(a) !== null) c.promissory_fires += 1;
129
- if (detectLanguage(a, pinned) !== null) {
130
- c.language_fires += 1;
131
- myLanguage += 1;
132
- }
170
+ c.assistant_entries += 1;
171
+ // Overwrite: only the last one in the turn is the gate's input.
172
+ pendingReply = a;
173
+ pendingPin = pinned;
133
174
  }
134
-
135
- const agree = Math.min(myLanguage, scannerLanguage);
136
- c.language_agree += agree;
137
- c.language_only += Math.max(0, myLanguage - scannerLanguage);
175
+ scoreTurn(); // the transcript's final turn
138
176
  }
139
177
 
140
178
  return c;
141
179
  }
142
180
 
143
181
  export function render(c: Counts): string {
144
- const pct = (n: number): string =>
145
- c.assistant_turns === 0 ? '0.0' : ((100 * n) / c.assistant_turns).toFixed(1);
182
+ const pct = (n: number): string => (c.turns === 0 ? '0.0' : ((100 * n) / c.turns).toFixed(1));
146
183
  return [
147
- `measure-turn-end-gate · ${c.sessions} sessions · ${c.assistant_turns} assistant turns`,
184
+ `measure-turn-end-gate · ${c.sessions} sessions · ${c.turns} turns · ${c.assistant_entries} assistant entries`,
148
185
  '',
149
186
  ` detector A (promissory) fires on ${c.promissory_fires} turns (${pct(c.promissory_fires)}%)`,
150
187
  ` detector B (language) fires on ${c.language_fires} turns (${pct(c.language_fires)}%)`,
151
- ` of which the independent scanner also flagged: ${c.language_agree}`,
152
- ` fired where the scanner saw nothing: ${c.language_only}`,
153
188
  '',
154
- ' The second line is the one that matters for a BLOCKING guard: those are',
155
- ' the turns this detector would have refused and the other instrument would',
156
- ' not. They are candidate false positives, not confirmed ones — the two',
157
- ' instruments read different spans (first prose line vs. whole stripped',
158
- ' reply), so disagreement is expected in both directions.',
189
+ ' Rates are per TURN, over the last assistant text of each turn — the same',
190
+ ' population the shipped gate inspects. The entry count is printed beside',
191
+ ' the turn count because the first version of this script scored every',
192
+ ' assistant ENTRY and labelled the result per-turn; the gap between the two',
193
+ ' numbers is the size of that error.',
194
+ '',
195
+ ` For context only, the independent scanner's own language total: ${c.scanner_language}`,
196
+ ' NOT an agreement figure. The two instruments read different spans (its',
197
+ ' first prose line vs. this stripped, lead-first-scored reply) and this',
198
+ ' script does no per-turn matching between them, so no overlap can be claimed from these',
199
+ ' two totals — the previous version claimed one from per-session min/max',
200
+ ' arithmetic, which reports disjoint findings of equal count as full',
201
+ ' agreement.',
159
202
  '',
160
203
  ' No recall figure for detector A: promissory closings are not machine-',
161
204
  ' scanned anywhere in this tree, so there is no ground truth to score.',
@@ -170,7 +213,18 @@ export function main(argv: string[] = process.argv.slice(2)): number {
170
213
  store = argv[i + 1]!;
171
214
  i += 1;
172
215
  } else if (argv[i] === '--limit' && argv[i + 1] !== undefined) {
173
- limit = parseInt(argv[i + 1]!, 10);
216
+ // R2 finding 15: an unvalidated parseInt made `--limit abc` yield
217
+ // NaN, `slice(0, NaN)` return nothing, and the script exit 2 with
218
+ // "no .jsonl sessions under <store>" — a real input error reported
219
+ // as a missing corpus.
220
+ const parsed = Number.parseInt(argv[i + 1]!, 10);
221
+ if (!Number.isFinite(parsed) || parsed < 1) {
222
+ process.stderr.write(
223
+ `measure-turn-end-gate: --limit must be a positive integer, got ${JSON.stringify(argv[i + 1])}\n`,
224
+ );
225
+ return 2;
226
+ }
227
+ limit = parsed;
174
228
  i += 1;
175
229
  }
176
230
  }
@@ -189,6 +243,30 @@ export function main(argv: string[] = process.argv.slice(2)): number {
189
243
  return 0;
190
244
  }
191
245
 
192
- if (process.argv[1] !== undefined && process.argv[1].includes('measure_turn_end_gate')) {
246
+ // Bundle-safety, same guard as the sibling hook. R2 finding 15: a substring
247
+ // test on argv[1] auto-runs the module when esbuild inlines it into a bundle,
248
+ // where every module shares the bundle's `import.meta.url`.
249
+ declare const __AGENT_CONFIG_BUNDLE__: boolean | undefined;
250
+ function _isCliEntry(): boolean {
251
+ if (typeof __AGENT_CONFIG_BUNDLE__ !== 'undefined' && __AGENT_CONFIG_BUNDLE__) {
252
+ return false;
253
+ }
254
+ if (process.argv[1] === undefined) {
255
+ return false;
256
+ }
257
+ const argvUrl = pathToFileURL(path.resolve(process.argv[1])).href;
258
+ if (import.meta.url === argvUrl) {
259
+ return true;
260
+ }
261
+ try {
262
+ const here = fs.realpathSync(fileURLToPath(import.meta.url));
263
+ const argv = fs.realpathSync(path.resolve(process.argv[1]));
264
+ return here === argv;
265
+ } catch {
266
+ return false;
267
+ }
268
+ }
269
+
270
+ if (_isCliEntry()) {
193
271
  process.exit(main());
194
272
  }
@@ -35,16 +35,30 @@
35
35
  * ignored, which is the failure `check-rule-invariants` already paid for once.
36
36
  * The point is that the condition becomes VISIBLE, not that it becomes illegal.
37
37
  *
38
- * THE THREE CLASSES, AND WHICH ONE A READER MUST ACT ON
39
- * ----------------------------------------------------
40
- * body-diff the two carriers deliver different TEXT. This is the class
38
+ * THE FOUR CLASSES, AND WHICH ONE A READER MUST ACT ON
39
+ * ---------------------------------------------------
40
+ * body-diff the two carriers deliver different PROSE. This is the class
41
41
  * round 5's contradiction lived in, and the only one that
42
- * needs a decision. Precedence when it appears: the project
43
- * projection is generated from `src/` at the current commit,
44
- * so it is the newer copy and it wins; the global install is
45
- * a release snapshot. Printed with the report so the
46
- * precedence is read at the moment it matters rather than
47
- * looked up.
42
+ * needs a decision. What the host does about it: NOTHING —
43
+ * both copies load at launch with the same priority and no
44
+ * precedence marker between them, so the divergence is
45
+ * binding-undefined rather than resolved in either
46
+ * direction. Cited, not inferred:
47
+ * `agents/evidence/analysis/claude-code-rules-dir-contract.md`
48
+ * (host 2.1.226, first-party observation plus the host's own
49
+ * documentation). The project copy is the NEWER text, which
50
+ * is a fact about recency and not a precedence rule — the
51
+ * earlier wording of this header called it a win, and a
52
+ * reader acting on that would have believed the host resolves
53
+ * something it does not.
54
+ * frontmatter-only the prose is byte-identical and only the metadata block
55
+ * differs. The host delivers the prose without that block, so
56
+ * nothing a reader must act on reaches the model differently.
57
+ * Split out because it was, measured, the ENTIRE content of
58
+ * the `body-diff` class: 109 of 109 on 2026-08-10. Left inside
59
+ * body-diff it manufactured the report's only actionable
60
+ * class out of a metadata difference — the same mistake the
61
+ * `unreadable` branch below already refuses to make.
48
62
  * provenance-only identical after removing the two installer ownership keys.
49
63
  * Structural, expected, and decided against closing —
50
64
  * `agents/settings/contexts/dedup-reachability-refusal.md`.
@@ -69,7 +83,7 @@ import * as path from 'node:path';
69
83
  import process from 'node:process';
70
84
  import { fileURLToPath, pathToFileURL } from 'node:url';
71
85
 
72
- import { comparePair, type PairVerdict } from './_lib/carrier_divergence.js';
86
+ import { comparePair, type PairVerdict, proseEqual } from './_lib/carrier_divergence.js';
73
87
 
74
88
  const _HERE = fileURLToPath(import.meta.url);
75
89
  const REPO_ROOT = path.resolve(path.dirname(_HERE), '..', '..');
@@ -103,6 +117,13 @@ export interface CarrierDivergence {
103
117
  shared: number;
104
118
  identical: string[];
105
119
  provenanceOnly: string[];
120
+ /**
121
+ * Prose byte-identical, metadata block different. A real byte difference — so
122
+ * `comparePair` still calls it `body-diff` and the dedup predicate is
123
+ * unchanged — but not a difference in what the host delivers, so it is not
124
+ * something a reader has to act on.
125
+ */
126
+ frontmatterOnly: string[];
106
127
  bodyDiff: string[];
107
128
  projectOnly: string[];
108
129
  globalOnly: string[];
@@ -171,6 +192,7 @@ export function compareCarriers(
171
192
  shared: 0,
172
193
  identical: [],
173
194
  provenanceOnly: [],
195
+ frontmatterOnly: [],
174
196
  bodyDiff: [],
175
197
  projectOnly: [],
176
198
  globalOnly: [],
@@ -196,11 +218,12 @@ export function compareCarriers(
196
218
  }
197
219
  out.shared += 1;
198
220
  let verdict: PairVerdict;
221
+ let proseSame: boolean;
199
222
  try {
200
- verdict = comparePair(
201
- fs.readFileSync(path.join(projectDir, rule)),
202
- fs.readFileSync(path.join(globalDir, rule)),
203
- );
223
+ const projectCopy = fs.readFileSync(path.join(projectDir, rule));
224
+ const globalCopy = fs.readFileSync(path.join(globalDir, rule));
225
+ verdict = comparePair(projectCopy, globalCopy);
226
+ proseSame = proseEqual(projectCopy.toString('utf-8'), globalCopy.toString('utf-8'));
204
227
  } catch {
205
228
  // An unreadable copy is not a body difference and must not be
206
229
  // reported as one — a permission error would otherwise manufacture
@@ -213,6 +236,7 @@ export function compareCarriers(
213
236
  }
214
237
  if (verdict === 'identical') out.identical.push(rule);
215
238
  else if (verdict === 'provenance-only') out.provenanceOnly.push(rule);
239
+ else if (proseSame) out.frontmatterOnly.push(rule);
216
240
  else out.bodyDiff.push(rule);
217
241
  }
218
242
  return out;
@@ -244,7 +268,8 @@ export function render(d: CarrierDivergence): string {
244
268
  lines.push(` shared rule names ${d.shared}`);
245
269
  lines.push(` byte-identical ${d.identical.length}`);
246
270
  lines.push(` differ ONLY in the install stamp ${d.provenanceOnly.length}`);
247
- lines.push(` differ in BODY ${d.bodyDiff.length}`);
271
+ lines.push(` differ ONLY in frontmatter ${d.frontmatterOnly.length}`);
272
+ lines.push(` differ in PROSE ${d.bodyDiff.length}`);
248
273
  if (d.unreadable.length > 0) {
249
274
  lines.push(` unreadable on one side ${d.unreadable.length} (${d.unreadable.join(', ')})`);
250
275
  lines.push(' Not counted as shared and NOT a body difference — a dangling entry is');
@@ -255,16 +280,34 @@ export function render(d: CarrierDivergence): string {
255
280
  lines.push('');
256
281
 
257
282
  if (d.bodyDiff.length > 0) {
258
- lines.push(' BODY DIVERGENCE — the two carriers deliver different text for these rules.');
259
- lines.push(' Both copies reach the model, with no precedence marker between them, so a');
260
- lines.push(' claim one copy retracts can be re-asserted by the other. PRECEDENCE: the');
261
- lines.push(' project projection is generated from src/ at this commit and wins; the');
262
- lines.push(' global install is a release snapshot. Close it by reinstalling the global');
263
- lines.push(' copy, or by reading the project copy as authoritative.');
283
+ lines.push(' PROSE DIVERGENCE — the two carriers deliver different governed text for');
284
+ lines.push(' these rules. Both copies reach the model, and the host resolves NOTHING:');
285
+ lines.push(' rules without a `paths` key load at launch with the same priority as');
286
+ lines.push(' CLAUDE.md, with no precedence marker between the layers, so a claim one');
287
+ lines.push(' copy retracts can be re-asserted by the other and which text binds is');
288
+ lines.push(' UNDEFINED. Cited, not inferred — agents/evidence/analysis/');
289
+ lines.push(' claude-code-rules-dir-contract.md (host 2.1.226, the host\'s own docs plus');
290
+ lines.push(' a first-party observation). The project copy is the NEWER text, which is a');
291
+ lines.push(' fact about recency and NOT a host precedence rule: close the divergence by');
292
+ lines.push(' reinstalling the global copy, or decide which text binds — do not assume');
293
+ lines.push(' the newer one already won.');
264
294
  for (const r of d.bodyDiff) lines.push(` - ${r}`);
265
295
  lines.push('');
266
296
  }
267
297
 
298
+ if (d.frontmatterOnly.length > 0) {
299
+ lines.push(` ${String(d.frontmatterOnly.length)} pair(s) differ ONLY in the frontmatter block — prose byte-identical.`);
300
+ lines.push(' Not a governed-text difference and nothing to act on: the host delivers the');
301
+ lines.push(' prose without that block, and none of the keys in it is one this host reads');
302
+ lines.push(' (claude-code-rules-dir-contract.md: it reads `paths`, and agent-config\'s own');
303
+ lines.push(' vocabulary is not read at all). Two writers, two frontmatter policies —');
304
+ lines.push(' `generate-tools` emits `paths` where a rule is path-scoped and nothing');
305
+ lines.push(' otherwise; `install.ts` writes the full vocabulary plus its ownership stamp.');
306
+ lines.push(' Reported as a count on purpose: naming every pair would bury the class');
307
+ lines.push(' above, which is the one a reader must act on.');
308
+ lines.push('');
309
+ }
310
+
268
311
  if (d.provenanceOnly.length > 0) {
269
312
  lines.push(` ${String(d.provenanceOnly.length)} pair(s) differ only by the installer's package:/source_path: stamp.`);
270
313
  lines.push(' Not a content difference and NOT a defect: install.ts stamps every installed');
@@ -302,9 +345,14 @@ export function render(d: CarrierDivergence): string {
302
345
 
303
346
  if (d.bodyDiff.length === 0) {
304
347
  lines.push('');
305
- lines.push(' No body divergence. The condition is transient — it returns whenever the');
306
- lines.push(' checkout moves ahead of the installed release — so this is a reading of');
307
- lines.push(' right now, not a property of the repo. Re-run it, do not cite it.');
348
+ lines.push(' No prose divergence — no rule\'s governed text differs between the carriers.');
349
+ lines.push(' The condition is transient — it returns whenever the checkout moves ahead of');
350
+ lines.push(' the installed release — so this is a reading of right now, not a property of');
351
+ lines.push(' the repo. Re-run it, do not cite it. That holds doubly here: the reading also');
352
+ lines.push(' depends on WHICH GENERATION of the emitter produced the project tree (a');
353
+ lines.push(' frontmatter-carrying symlink tree and a frontmatter-less real-file tree');
354
+ lines.push(' classify the same commit differently), so a number from another checkout is');
355
+ lines.push(' not comparable to this one.');
308
356
  }
309
357
  return lines.join('\n');
310
358
  }
@@ -146,15 +146,22 @@ export function render(r: FunnelReport): string {
146
146
  lines.push(' substituted with dist/, which would answer a different question.');
147
147
  } else {
148
148
  lines.push(
149
- ` Divergence: ${d.shared} shared · ${d.bodyDiff.length} body-diff · ` +
149
+ ` Divergence: ${d.shared} shared · ${d.bodyDiff.length} prose-diff · ` +
150
+ `${d.frontmatterOnly.length} frontmatter-only · ` +
150
151
  `${d.provenanceOnly.length} provenance-only · ${d.projectOnly.length} project-only · ` +
151
152
  `${d.globalOnly.length} global-only` +
152
153
  (d.manualOnlyGlobal.length > 0 ? ` (${d.manualOnlyGlobal.length} by design, ADR-004 manual)` : ''),
153
154
  );
154
155
  if (d.bodyDiff.length > 0) {
155
- lines.push(` BODY DIVERGENCE — both copies reach the model: ${d.bodyDiff.join(', ')}`);
156
- lines.push(' Precedence: the project projection is generated from src/ at this commit');
157
- lines.push(' and wins; the global install is a release snapshot.');
156
+ lines.push(` PROSE DIVERGENCE — both copies reach the model: ${d.bodyDiff.join(', ')}`);
157
+ lines.push(' The host resolves nothing: both layers load at launch at the same');
158
+ lines.push(' priority with no precedence marker, so which text binds is UNDEFINED');
159
+ lines.push(' (claude-code-rules-dir-contract.md). The project copy is merely the');
160
+ lines.push(' newer one — recency, not precedence.');
161
+ }
162
+ if (d.frontmatterOnly.length > 0) {
163
+ lines.push(` ${String(d.frontmatterOnly.length)} pair(s) differ only in frontmatter — prose byte-identical, nothing`);
164
+ lines.push(' to act on; the host delivers the prose without that block.');
158
165
  }
159
166
  if (d.unreadable.length > 0) {
160
167
  lines.push(` Unreadable on one side (${d.unreadable.length}) — a broken install, not a disagreement.`);
@@ -525,10 +525,10 @@ export const settingsSchema = z.object({
525
525
  'Stop-slot turn-end gate (road-to-conformance-round5 Phase 3). Default off, and it is the only concern that can REFUSE a turn-end rather than a tool call — so it soaks behind an explicit opt-in before it binds. Round 5 measured the reason: both blocking carriers reached zero violations, neither advisory carrier did, and 19 wrong-language replies survived a pin that had fired 26-35 seconds earlier. A refused turn continues in the SAME turn and is never refused twice.',
526
526
  ),
527
527
  promissory: z.boolean().default(true).describe(
528
- 'Detector A of the turn-end gate: refuse a turn whose closing paragraph promises work not yet performed while asking the user nothing (20 measured occurrences). Inert while hooks.turn_end_gate.enabled is false.',
528
+ 'Detector A of the turn-end gate: refuse a turn whose closing paragraph promises work not yet performed while asking the user nothing (20 measured occurrences). No fire rate is quoted here on purpose: the measured rate moved twice while the instrument itself was being corrected, so a number in a shipped description would be a moving target. Re-derive it with `measure-turn-end-gate --store <transcript dir>` against your own corpus before arming the gate. Inert while hooks.turn_end_gate.enabled is false.',
529
529
  ),
530
530
  language: z.boolean().default(true).describe(
531
- 'Detector B of the turn-end gate: refuse a turn whose user-visible prose is not in the pinned language, with fenced code, inline code, quoted tool output, tables, URLs, paths and identifiers excluded first (19 measured occurrences). Fires 18.0% of turns on the measured corpus, which is why the master switch is off by default. Inert while hooks.turn_end_gate.enabled is false.',
531
+ 'Detector B of the turn-end gate: refuse a turn whose reply is not in the pinned language (19 measured occurrences). Fenced code, inline code, quoted tool output, tables, URLs and paths are stripped first, and the remainder is then scored LEAD-FIRST by the same classifier that sets the pin — so the verdict usually comes from the reply opening, not from all of it. Sharing that classifier is deliberate: it stops the pin and the detector disagreeing about a turn. Accepted opt-in spellings for the switches below include YAML true/false and yes/no/on/off. Inert while hooks.turn_end_gate.enabled is false.',
532
532
  ),
533
533
  }).default({}),
534
534
  }),