@event4u/agent-config 9.30.0 → 9.32.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 (94) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/CHANGELOG.md +60 -292
  3. package/dist/agent-src/contexts/execution/host-capability-manifest.md +32 -1
  4. package/dist/agent-src/rules/delegation-policy.md +11 -1
  5. package/dist/agent-src/templates/agents/agent-project-settings.example.yml +1 -1
  6. package/dist/agent-src/templates/scripts/work_engine/delivery_state.ts +6 -0
  7. package/dist/agent-src/templates/scripts/work_engine/directives/backend/_self_fix.ts +332 -0
  8. package/dist/agent-src/templates/scripts/work_engine/directives/backend/refine.ts +106 -1
  9. package/dist/agent-src/templates/scripts/work_engine/directives/backend/test.ts +50 -15
  10. package/dist/agent-src/templates/scripts/work_engine/directives/backend/verify.ts +49 -15
  11. package/dist/agent-src/templates/scripts/work_engine/state.ts +74 -0
  12. package/dist/agent-src/templates/scripts/work_engine/state_io.ts +6 -0
  13. package/dist/cli-delegate/chunk-2WELHO3P.js +582 -0
  14. package/dist/cli-delegate/{chunk-IYTGIG2Y.js → chunk-542DZR4P.js} +118 -148
  15. package/dist/cli-delegate/{chunk-66OVWGB5.js → chunk-AG47LAGQ.js} +1 -1
  16. package/dist/cli-delegate/{chunk-MP3JT3IU.js → chunk-DS6BLDMG.js} +1 -1
  17. package/dist/cli-delegate/{chunk-O3CO5BFB.js → chunk-EJITJBJX.js} +12 -1
  18. package/dist/cli-delegate/{chunk-EQY4FLJB.js → chunk-ITIQSGOQ.js} +3 -3
  19. package/dist/cli-delegate/{chunk-LCFJIQ4J.js → chunk-JEJABGKB.js} +110 -6
  20. package/dist/cli-delegate/chunk-PZWRPRWP.js +38 -0
  21. package/dist/cli-delegate/{chunk-BTJNRUX3.js → chunk-QEEB4LHO.js} +1 -1
  22. package/dist/cli-delegate/{chunk-DDUNNFYX.js → chunk-UVKNIJH2.js} +2 -2
  23. package/dist/cli-delegate/cmd_analyze_session.js +1 -1
  24. package/dist/cli-delegate/cmd_brand_status.js +1 -1
  25. package/dist/cli-delegate/cmd_conformance.js +154 -41
  26. package/dist/cli-delegate/cmd_doctor.js +4 -3
  27. package/dist/cli-delegate/cmd_explain.js +2 -2
  28. package/dist/cli-delegate/cmd_export.js +4 -4
  29. package/dist/cli-delegate/cmd_handoff.js +168 -59
  30. package/dist/cli-delegate/cmd_mcp_available.js +1 -1
  31. package/dist/cli-delegate/cmd_migrate.js +2 -2
  32. package/dist/cli-delegate/cmd_packs_active.js +2 -2
  33. package/dist/cli-delegate/cmd_prune.js +4 -4
  34. package/dist/cli-delegate/cmd_route_audit.js +2 -2
  35. package/dist/cli-delegate/cmd_route_explain.js +1 -1
  36. package/dist/cli-delegate/cmd_session_recycle.js +19 -4
  37. package/dist/cli-delegate/cmd_settings_get.js +2 -55
  38. package/dist/cli-delegate/cmd_settings_migrate.js +4 -4
  39. package/dist/cli-delegate/cmd_settings_set.js +1 -1
  40. package/dist/cli-delegate/cmd_sync.js +4 -4
  41. package/dist/cli-delegate/cmd_uninstall.js +4 -4
  42. package/dist/cli-delegate/cmd_update.js +1 -1
  43. package/dist/cli-delegate/cmd_upgrade.js +1 -1
  44. package/dist/cli-delegate/cmd_validate.js +4 -4
  45. package/dist/cli-delegate/cmd_versions.js +1 -1
  46. package/dist/discovery/deprecation-report.md +1 -1
  47. package/dist/discovery/discovery-manifest.json +3 -3
  48. package/dist/discovery/discovery-manifest.json.sha256 +1 -1
  49. package/dist/discovery/discovery-manifest.summary.md +1 -1
  50. package/dist/discovery/orphan-report.md +1 -1
  51. package/dist/discovery/packs.json +2 -2
  52. package/dist/discovery/trust-report.md +1 -1
  53. package/dist/discovery/workspaces.json +2 -2
  54. package/dist/hooks/dispatch.js +1411 -810
  55. package/dist/install/install.mjs +121 -16
  56. package/dist/mcp/registry-manifest.json +1 -1
  57. package/dist/mcp/server.json +2 -2
  58. package/dist/mcp/server.mjs +365 -143
  59. package/dist/scripts/_lib/agent_settings.js +111 -2
  60. package/dist/scripts/_lib/agent_settings.js.map +1 -1
  61. package/dist/server/schemas/settings.js +2 -2
  62. package/dist/server/schemas/settings.js.map +1 -1
  63. package/package.json +1 -1
  64. package/src/agent-src/templates/scripts/work_engine/delivery_state.ts +6 -0
  65. package/src/agent-src/templates/scripts/work_engine/directives/backend/_self_fix.ts +332 -0
  66. package/src/agent-src/templates/scripts/work_engine/directives/backend/refine.ts +106 -1
  67. package/src/agent-src/templates/scripts/work_engine/directives/backend/test.ts +50 -15
  68. package/src/agent-src/templates/scripts/work_engine/directives/backend/verify.ts +49 -15
  69. package/src/agent-src/templates/scripts/work_engine/state.ts +74 -0
  70. package/src/agent-src/templates/scripts/work_engine/state_io.ts +6 -0
  71. package/src/config/pack-size-budget.json +3 -2
  72. package/src/scripts/_cli/cmd_session_recycle.ts +25 -0
  73. package/src/scripts/_cli/explain_last/inputs.ts +13 -0
  74. package/src/scripts/_cli/handoff_generate.ts +36 -1
  75. package/src/scripts/_cli/handoff_sessions.ts +140 -9
  76. package/src/scripts/_lib/agent_settings.ts +119 -2
  77. package/src/scripts/_lib/carrier_divergence.ts +120 -2
  78. package/src/scripts/_lib/envelope_grounding.ts +206 -0
  79. package/src/scripts/_lib/host_capability.ts +79 -0
  80. package/src/scripts/_lib/session_eol.ts +85 -0
  81. package/src/scripts/_lib/subagent_capsule.ts +256 -1
  82. package/src/scripts/check_secret_leak.test.ts +43 -0
  83. package/src/scripts/check_secret_leak.ts +12 -0
  84. package/src/scripts/handoff_context_hook.ts +66 -18
  85. package/src/scripts/hooks/session_eol_hook.ts +12 -12
  86. package/src/scripts/hooks/turn_end_gate_hook.ts +296 -88
  87. package/src/scripts/language_mirror_hook.ts +69 -16
  88. package/src/scripts/measure_turn_end_gate.ts +129 -51
  89. package/src/scripts/report_carrier_divergence.ts +143 -30
  90. package/src/scripts/report_conformance_funnel.ts +18 -4
  91. package/src/scripts/routing_doctor.ts +25 -2
  92. package/src/scripts/schemas/dod.schema.json +45 -0
  93. package/src/server/schemas/settings.ts +2 -2
  94. package/dist/cli-delegate/chunk-RVGBH4XK.js +0 -142
@@ -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
  }