@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
@@ -81,6 +81,53 @@ export function comparePair(a: Buffer, b: Buffer): PairVerdict {
81
81
  return 'body-diff';
82
82
  }
83
83
 
84
+ /**
85
+ * Drop a leading YAML frontmatter block, returning the prose the host actually
86
+ * delivers.
87
+ *
88
+ * A file with no `---` fence, or an unterminated one, is returned unchanged —
89
+ * the same tolerance `report_carrier_divergence`'s own `type:` reader applies,
90
+ * because a malformed fence must not silently swallow a rule's whole text.
91
+ */
92
+ export function stripFrontmatter(text: string): string {
93
+ if (!text.startsWith('---')) return text;
94
+ const end = text.indexOf('\n---', 3);
95
+ if (end === -1) return text;
96
+ const afterFence = text.indexOf('\n', end + 1);
97
+ return afterFence === -1 ? '' : text.slice(afterFence + 1);
98
+ }
99
+
100
+ /**
101
+ * Do the two copies carry the same governed PROSE, ignoring frontmatter?
102
+ *
103
+ * WHY THIS IS SEPARATE FROM `comparePair`, AND MUST STAY SEPARATE
104
+ * --------------------------------------------------------------
105
+ * `comparePair` answers *can a byte-identity dedup skip this pair* — it is the
106
+ * dedup predicate, and a frontmatter difference is a real byte difference, so
107
+ * folding this in would relax the predicate that
108
+ * `agents/settings/contexts/dedup-reachability-refusal.md` deliberately keeps
109
+ * strict. `measure_scope_dedup` asks exactly that question and its arithmetic
110
+ * stays untouched.
111
+ *
112
+ * This answers the different, reader-facing question `report_carrier_divergence`
113
+ * asks: *must a human act on this pair*. Measured 2026-08-10 over the live
114
+ * carriers at commit `a5b2f4cb7`: 109 shared rules, **0** byte-identical, **109**
115
+ * classified `body-diff` — and after this strip, **0** of the 109 differ in
116
+ * prose. The whole divergence was the metadata block. Reporting that as body
117
+ * divergence manufactures the one class the report tells a reader to act on,
118
+ * which is precisely the failure `compareCarriers` already refuses to commit for
119
+ * an unreadable copy.
120
+ *
121
+ * Edge trimming only, on purpose: the frontmatter fence leaves the two sides one
122
+ * leading newline apart, and that is not a content difference. Internal
123
+ * whitespace and line endings are NOT normalized — a CRLF-vs-LF body stays a
124
+ * prose difference, so this stays no softer than the dedup predicate on the text
125
+ * the host reads.
126
+ */
127
+ export function proseEqual(a: string, b: string): boolean {
128
+ return stripFrontmatter(a).trim() === stripFrontmatter(b).trim();
129
+ }
130
+
84
131
  export interface RuleDirCensus {
85
132
  files: number;
86
133
  /**
@@ -27,16 +27,30 @@
27
27
  * Nothing else in `src/` reads this field today; it is the host's own
28
28
  * answer to the question above.
29
29
  *
30
- * 2. A state marker keyed on the LAST USER MESSAGE, never on the reply.
31
- * After a refusal the model writes a *new* reply, so a reply-keyed
32
- * marker would let the same turn be refused again, and again. The
33
- * user prompt does not change within a turn, so the same turn yields
34
- * the same key and is refused at most once; the next genuine prompt
35
- * yields a new key and the gate re-arms itself with no cleanup.
30
+ * 2. A per-session state file holding the ORDINAL of the last refused turn —
31
+ * never the prompt's text, and never the reply. One file per session, not
32
+ * one per refused turn.
33
+ *
34
+ * The first version keyed on sha256(session_id + last user text) with a
35
+ * file per turn, and R2 found that wrong in three directions at once: a
36
+ * REPEATED prompt ("weiter", "ok", "1") collided with the earlier refused
37
+ * turn and was allowed unconditionally; the key drifted WITHIN a turn
38
+ * because a compaction summary, a `<system-reminder>` and a sidechain
39
+ * prompt all arrive in the user role; and the files accumulated with no
40
+ * TTL. The ordinal — a count of `isSyntheticPrompt`-filtered,
41
+ * non-sidechain user entries — is distinct for every real turn whatever
42
+ * the user typed, and stable across harness injections.
43
+ *
44
+ * Still true and still worth stating: per-session files are bounded per
45
+ * session but not pruned, so a long-lived workspace accumulates one small
46
+ * file per session. No TTL ships here.
36
47
  *
37
48
  * The failure this ordering prevents is not a loop but a wedge: a turn that
38
49
  * can never end. Layer 2 also covers the host that does not send
39
- * `stop_hook_active` at all.
50
+ * `stop_hook_active` at all — with one honest limit: a host that sends no
51
+ * `session_id` either falls into a shared bucket where an unrelated session's
52
+ * matching ordinal reads as already-refused, so on such a host layer 2
53
+ * degrades to "may under-refuse" and layer 1 is the real guard.
40
54
  *
41
55
  * ## Default OFF — and why, given the roadmap says otherwise
42
56
  *
@@ -49,7 +63,7 @@
49
63
  * (`hooks.turn_end_gate.enabled`) is default OFF, and WITHIN it both
50
64
  * detectors are default ON. A maintainer who opts in gets both without
51
65
  * further configuration; until then this concern is a no-op that costs one
52
- * settings read.
66
+ * settings-cascade read per turn-end.
53
67
  *
54
68
  * CONTRACT: dispatcher-internal exit is 1 (EXIT_BLOCK) on a fire, 0
55
69
  * otherwise. `fail_closed: false` — deliberately. A crash in a turn-end
@@ -63,7 +77,13 @@ import * as fs from 'node:fs';
63
77
  import * as path from 'node:path';
64
78
  import { fileURLToPath, pathToFileURL } from 'node:url';
65
79
 
66
- import { classify, STATE_FILE as LANGUAGE_STATE_FILE, type Verdict } from '../language_mirror_hook.js';
80
+ import { load_agent_settings } from '../_lib/agent_settings.js';
81
+ import {
82
+ classify,
83
+ isSyntheticPrompt,
84
+ STATE_FILE as LANGUAGE_STATE_FILE,
85
+ type Verdict,
86
+ } from '../language_mirror_hook.js';
67
87
  import { isSafeTranscriptPath } from './end_review_nudge_hook.js';
68
88
  import { unwrap, type JsonObject, type JsonValue } from './envelope.js';
69
89
  import { readHookStdin } from './hook_stdin.js';
@@ -73,6 +93,16 @@ import { atomic_write_json, is_replay_mode } from './state_io.js';
73
93
  const EXIT_BLOCK = 1;
74
94
  const EXIT_ALLOW = 0;
75
95
 
96
+ /**
97
+ * Transcript-read ceiling for this hook, passed at the call site in `main()`.
98
+ * Deliberately well under `isSafeTranscriptPath`'s own 50 MB refusal: the
99
+ * ordinal is a count over every entry, so the whole file is walked once per
100
+ * turn-end, and a session file grows for the life of the session. Past this the
101
+ * gate lets the turn end rather than pay an unbounded read — fail-open, like
102
+ * every other unreadable-transcript case here.
103
+ */
104
+ export const TRANSCRIPT_READ_MAX_BYTES = 8 * 1024 * 1024; // 8 MB
105
+
76
106
  /** Detector identity, used in the state marker and the refusal text. */
77
107
  export type DetectorId = 'promissory' | 'language';
78
108
 
@@ -97,12 +127,56 @@ export interface Finding {
97
127
  * under-stripping costs a false refusal, and a blocking guard with a false
98
128
  * positive rate teaches users to bypass it.
99
129
  */
130
+ /**
131
+ * Remove fenced code blocks, line by line, tracking open/close state.
132
+ *
133
+ * This is deliberately NOT a regex, and the reason is a measured regression.
134
+ * Two regex attempts each dropped a reply's whole tail on a different valid
135
+ * shape: a `\1` backreference misses CommonMark's longer closing fence (R2
136
+ * round 1, finding 5), and the character-matching replacement that fixed THAT
137
+ * stopped at the first closer-shaped line — so on a `~~~` block containing a
138
+ * ``` block, the inner line was taken for the closer, the replacer declined it
139
+ * on the character mismatch, and the greedy tail-drop then deleted everything
140
+ * from the opener onward (R2 round 2, finding 1). That mixed-character nesting
141
+ * is the shape `markdown-safe-codeblocks` prescribes as its DEFAULT, so the
142
+ * second attempt was worse than the first on the more common input.
143
+ *
144
+ * A scanner has the state a regex lacks. CommonMark, applied here:
145
+ * · an opener may carry an info string; a closer may not;
146
+ * · only a fence of the SAME character and at least the opener's length
147
+ * closes the block — anything else inside it is content;
148
+ * · an unterminated opener runs to end of input, so a truncated reply loses
149
+ * its tail, which is the correct reading of a truncated reply.
150
+ */
151
+ export function stripFencedBlocks(text: string): string {
152
+ const out: string[] = [];
153
+ let open: { ch: string; len: number } | null = null;
154
+ for (const line of text.split('\n')) {
155
+ const m = /^[ \t]{0,3}([`~]{3,})(.*)$/.exec(line);
156
+ if (open === null) {
157
+ if (m) {
158
+ open = { ch: m[1]![0]!, len: m[1]!.length };
159
+ out.push(' ');
160
+ continue;
161
+ }
162
+ out.push(line);
163
+ continue;
164
+ }
165
+ if (
166
+ m &&
167
+ m[1]![0] === open.ch &&
168
+ m[1]!.length >= open.len &&
169
+ (m[2] ?? '').trim() === ''
170
+ ) {
171
+ open = null;
172
+ }
173
+ // Everything between opener and closer is dropped, closer included.
174
+ }
175
+ return out.join('\n');
176
+ }
177
+
100
178
  export function visibleProse(reply: string): string {
101
- let text = reply;
102
- // Fenced blocks, both ``` and ~~~ (markdown-safe-codeblocks ships both).
103
- text = text.replace(/^[ \t]*(`{3,}|~{3,})[\s\S]*?^[ \t]*\1[ \t]*$/gm, ' ');
104
- // An unterminated fence at the end of a reply — drop the tail.
105
- text = text.replace(/^[ \t]*(`{3,}|~{3,})[\s\S]*$/m, ' ');
179
+ let text = stripFencedBlocks(reply);
106
180
  // Inline code.
107
181
  text = text.replace(/`[^`\n]*`/g, ' ');
108
182
  // Block quotes — the shape quoted tool output takes.
@@ -146,10 +220,28 @@ const PROMISSORY = [
146
220
  /\bals n(ä|ae)chstes (werde|mache|baue|pr(ü|ue)fe) ich\b/i,
147
221
  // German puts the infinitive at the END of the clause ("ich werde jetzt
148
222
  // die Tests schreiben"), so the verb cannot be matched adjacent to
149
- // "werde" — it has to be looked for across the rest of the sentence. The
150
- // negative lookahead keeps a stated REFUSAL to act ("ich werde das nicht
151
- // anfassen") out: declining to do something is not a promise to do it.
152
- /\bich werde\b(?![^.!?\n]*\bnicht\b)[^.!?\n]*\b\w{3,}en\b/i,
223
+ // "werde" — it has to be looked for across the rest of the sentence.
224
+ //
225
+ // The lookahead excludes a stated REFUSAL to act: declining to do
226
+ // something is not a promise to do it. R2 finding 4 reproduced four live
227
+ // false refusals against the old `\bnicht\b`-only version — "Ich werde
228
+ // nichts anfassen" (no word boundary after `nicht`), "keine Tests
229
+ // schreiben", "niemals raten", and the passive non-promise "Ich werde
230
+ // gefragt, ob …". Each was a false refusal on a BLOCKING guard, which is
231
+ // the precision failure the council warning is about.
232
+ // `\p{L}` with the `u` flag, NOT `\w`: R2 round 2, finding 15 verified that
233
+ // `\b\w{3,}en\b` cannot reach an infinitive whose post-umlaut fragment is
234
+ // short, because `ü`/`ö`/`ä` are not `\w` and create a word boundary — so
235
+ // "prüfen" and "lösen" were silent while the comment above claimed the
236
+ // verb-final construction was handled.
237
+ //
238
+ // Honest about what this proxy matches: any word of 3+ letters ending in
239
+ // "en" within the clause, which is the infinitive in practice but also hits
240
+ // a plural noun ("die Zeilen zählen" matches on "Zeilen"). That is
241
+ // acceptable here — the clause already requires "ich werde" and excludes
242
+ // negations and passives, so a forward commitment is what remains — but it
243
+ // is a proxy for the infinitive, not a parse of one.
244
+ /\bich werde\b(?![^.!?\n]*\b(?:nicht|nichts|kein(?:e|en|em|er|es)?|niemals|nie)\b)(?![^.!?\n]*\b(?:gefragt|gebeten|informiert|benachrichtigt)\b)[^.!?\n]*(?<!\p{L})\p{L}{3,}en(?!\p{L})/iu,
153
245
  /\bI(?:'| wi)ll (report|let you know|update you|follow up)\b/i,
154
246
  /\bI(?:'| wi)ll (now |then )?\w+ (it|that|this|next)\b/i,
155
247
  /\bnext,? I(?:'| wi)ll\b/i,
@@ -182,8 +274,13 @@ export function detectPromissory(reply: string): Finding | null {
182
274
  const tail = finalParagraph(reply);
183
275
  if (!tail) return null;
184
276
 
185
- // A blocking question IS the stop condition — never refuse one.
186
- if (tail.includes('?')) return null;
277
+ // A blocking question IS the stop condition — never refuse one. But only a
278
+ // question that ENDS the paragraph is that: R2 round 2, finding 16 called
279
+ // `includes('?')` a one-character, trivially learnable bypass, since a
280
+ // rhetorical or quoted question anywhere alongside a promise disabled a
281
+ // blocking guard. The hand-back list below still covers the phrasings that
282
+ // yield without a question mark.
283
+ if (/\?["'’)\]]*\s*$/.test(tail)) return null;
187
284
  if (HANDBACK.some((re) => re.test(tail))) return null;
188
285
 
189
286
  for (const re of PROMISSORY) {
@@ -228,6 +325,23 @@ export function readLanguagePin(workspaceRoot: string): Verdict {
228
325
  * Fires only when the classifier is CONFIDENT the prose is the other
229
326
  * language. `classify` returns `und` below its marker floor, and `und`
230
327
  * never fires — a short reply is not evidence of drift.
328
+ *
329
+ * WHAT IS ACTUALLY CLASSIFIED, stated accurately because R2 finding 9 caught
330
+ * this file claiming otherwise in three places. `visibleProse` strips code,
331
+ * quotes, tables, URLs and paths from the whole reply — but `classify` then
332
+ * applies `instructionText` (which drops output-shaped lines and their
333
+ * followers) and `humanAuthoredLead`, and RETURNS ON THE LEAD ALONE when the
334
+ * lead is determined. So in the common case the verdict comes from the reply's
335
+ * opening chunk, not from all of it, and indented prose is discarded before
336
+ * scoring.
337
+ *
338
+ * That is deliberately left as-is rather than swapped for a full-text scorer:
339
+ * sharing one classifier with `language_mirror_hook` is what stops the pin and
340
+ * the detector disagreeing about what language a turn is in, and changing the
341
+ * scoring surface would change the measured rate, which needs its own
342
+ * measurement rather than a quiet edit. What is fixed here is the CLAIM —
343
+ * including the one this function used to put in its own refusal text, where an
344
+ * inflated description of the evidence is worst.
231
345
  */
232
346
  export function detectLanguage(reply: string, pinned: Verdict): Finding | null {
233
347
  if (pinned !== 'de' && pinned !== 'en') return null;
@@ -239,9 +353,10 @@ export function detectLanguage(reply: string, pinned: Verdict): Finding | null {
239
353
  detector: 'language',
240
354
  evidence: prose.trim().slice(0, 120),
241
355
  reason:
242
- `the pinned reply language is "${pinned}" but the user-visible prose classifies as ` +
243
- `"${verdict.language}" (${verdict.de_markers} de / ${verdict.en_markers} en markers, ` +
244
- 'code, quotes, paths and identifiers already excluded)',
356
+ `the pinned reply language is "${pinned}" but the reply classifies as ` +
357
+ `"${verdict.language}" (${verdict.de_markers} de / ${verdict.en_markers} en markers; ` +
358
+ 'code, quotes, tables, URLs and paths excluded, then scored lead-first ' +
359
+ 'by the same classifier that sets the pin)',
245
360
  };
246
361
  }
247
362
 
@@ -253,35 +368,75 @@ function stateDir(workspaceRoot: string): string {
253
368
  return path.join(workspaceRoot, 'agents', 'runtime', 'state', 'turn-end-gate');
254
369
  }
255
370
 
256
- function stateFile(workspaceRoot: string, turnKey: string): string {
257
- return path.join(stateDir(workspaceRoot), `${turnKey}.json`);
258
- }
259
-
260
371
  /**
261
- * The turn's identity. Session id plus the last USER message — see the file
262
- * header for why the reply must not be part of it.
372
+ * ONE file per session, not one per refused turn. R2 round 1, finding 17:
373
+ * per-turn files accumulated without bound and without a TTL, because "re-arms
374
+ * itself with no cleanup" described the key rotating, not the files being
375
+ * removed.
376
+ *
377
+ * HALF of that is fixed and half is not, stated rather than implied (R2 round 2,
378
+ * finding 12): the per-turn MULTIPLICITY is gone, so a session no longer leaves
379
+ * one file per refused turn. There is still no TTL and no pruning, so a
380
+ * long-lived workspace accumulates one small file per session that ever had a
381
+ * turn refused. Adding expiry is new behaviour and belongs with its own test.
263
382
  */
264
- export function deriveTurnKey(sessionId: string, lastUserText: string): string {
265
- return crypto
266
- .createHash('sha256')
267
- .update(`${sessionId}\n${lastUserText}`)
268
- .digest('hex')
269
- .slice(0, 32);
383
+ function sessionStateFile(workspaceRoot: string, sessionKey: string): string {
384
+ return path.join(stateDir(workspaceRoot), `${sessionKey}.json`);
385
+ }
386
+
387
+ export function deriveSessionKey(sessionId: string): string {
388
+ return crypto.createHash('sha256').update(sessionId).digest('hex').slice(0, 32);
270
389
  }
271
390
 
272
- export function alreadyRefusedThisTurn(workspaceRoot: string, turnKey: string): boolean {
391
+ /**
392
+ * A turn's identity is its ORDINAL — how many genuine user prompts the
393
+ * transcript has carried — never the prompt's text.
394
+ *
395
+ * R2 findings 2 and 3 killed the text-keyed version, in both directions:
396
+ *
397
+ * · keying on sha256(session + last user text) made every REPEAT of a prompt
398
+ * collide with the earlier refused turn, so the second "weiter" / "ok" /
399
+ * "1" was allowed unconditionally. Repeated short continuations are the
400
+ * dominant prompt shape in the corpus this gate was built from, so the
401
+ * gate disabled itself exactly where it was needed;
402
+ * · taking the last user-role entry made the key drift WITHIN one turn,
403
+ * because a compaction summary and a `<system-reminder>` both arrive in the
404
+ * user role. A new key mid-turn re-arms the marker, which is the wedge
405
+ * layer 2 exists to prevent on hosts that send no `stop_hook_active`.
406
+ *
407
+ * An ordinal over `isSyntheticPrompt`-filtered entries fixes both at once: it
408
+ * is distinct for every real turn regardless of what the user typed, and it
409
+ * does not move when the harness injects a user-role entry.
410
+ */
411
+ export function alreadyRefusedTurn(
412
+ workspaceRoot: string,
413
+ sessionKey: string,
414
+ turnOrdinal: number,
415
+ ): boolean {
273
416
  try {
274
- return fs.existsSync(stateFile(workspaceRoot, turnKey));
417
+ const raw = fs.readFileSync(sessionStateFile(workspaceRoot, sessionKey), 'utf-8');
418
+ const decoded: unknown = JSON.parse(raw);
419
+ if (typeof decoded === 'object' && decoded !== null && !Array.isArray(decoded)) {
420
+ return (decoded as Record<string, unknown>)['refused_turn'] === turnOrdinal;
421
+ }
275
422
  } catch {
276
- return false;
423
+ // absent, unreadable, or malformed state means "not refused yet" —
424
+ // fail-open, per this hook's contract.
277
425
  }
426
+ return false;
278
427
  }
279
428
 
280
- function markRefusedThisTurn(workspaceRoot: string, turnKey: string, detector: DetectorId): void {
429
+ function markRefusedTurn(
430
+ workspaceRoot: string,
431
+ sessionKey: string,
432
+ turnOrdinal: number,
433
+ detector: DetectorId,
434
+ ): void {
281
435
  if (is_replay_mode()) return;
282
436
  try {
283
- atomic_write_json(stateFile(workspaceRoot, turnKey), {
437
+ atomic_write_json(sessionStateFile(workspaceRoot, sessionKey), {
284
438
  refused_at: new Date().toISOString(),
439
+ refused_turn: turnOrdinal,
285
440
  detector,
286
441
  });
287
442
  } catch {
@@ -297,28 +452,48 @@ function markRefusedThisTurn(workspaceRoot: string, turnKey: string, detector: D
297
452
 
298
453
  export interface TranscriptTail {
299
454
  lastAssistant: string;
300
- lastUser: string;
455
+ /**
456
+ * How many GENUINE user prompts the transcript carries — harness-injected
457
+ * user-role entries excluded via `isSyntheticPrompt`. This is the turn's
458
+ * identity; see `alreadyRefusedTurn` for why the prompt's text is not.
459
+ */
460
+ turnOrdinal: number;
301
461
  }
302
462
 
303
463
  /**
304
- * Last assistant text and last user text from a Claude JSONL transcript.
305
- * Mirrors `chat_history._extract_claude_transcript_response` for the
306
- * assistant half and adds the user half the turn key needs.
464
+ * The last assistant text, plus the turn ordinal, from a Claude JSONL
465
+ * transcript. Mirrors `chat_history._extract_claude_transcript_response` for
466
+ * the assistant half.
467
+ *
468
+ * `isSyntheticPrompt` is applied to every user-role entry — the same filter
469
+ * `language_mirror_hook` uses, and for the same reason it was added there
470
+ * (round-5 § 6.5): a background-task notification and a `<system-reminder>`
471
+ * both occupy the user role without being chat messages. Counting them would
472
+ * move the turn ordinal mid-turn, which is R2 finding 3.
307
473
  */
308
474
  export function readTranscriptTail(
309
475
  transcriptPath: string,
310
476
  opts: { homeDir?: string; maxBytes?: number } = {},
311
477
  ): TranscriptTail {
312
- const empty: TranscriptTail = { lastAssistant: '', lastUser: '' };
478
+ const empty: TranscriptTail = { lastAssistant: '', turnOrdinal: 0 };
313
479
  if (!transcriptPath || !isSafeTranscriptPath(transcriptPath, opts)) return empty;
314
480
  let lines: string[];
315
481
  try {
482
+ // R2 finding 12: `maxBytes` was accepted in the options type and never
483
+ // used, so the declared cap was decoration. It is enforced here as well
484
+ // as inside `isSafeTranscriptPath`, because this is the read it bounds.
485
+ // The whole file still has to be walked — the turn ordinal is a count
486
+ // over all entries, not something a tail can answer — so the cap is the
487
+ // guard, not an optimisation.
488
+ if (opts.maxBytes !== undefined && fs.statSync(transcriptPath).size > opts.maxBytes) {
489
+ return empty;
490
+ }
316
491
  lines = fs.readFileSync(transcriptPath, 'utf-8').split('\n');
317
492
  } catch {
318
493
  return empty;
319
494
  }
320
495
  let lastAssistant = '';
321
- let lastUser = '';
496
+ let turnOrdinal = 0;
322
497
  for (const rawLine of lines) {
323
498
  const line = rawLine.trim();
324
499
  if (!line) continue;
@@ -330,16 +505,24 @@ export function readTranscriptTail(
330
505
  }
331
506
  if (typeof obj !== 'object' || obj === null || Array.isArray(obj)) continue;
332
507
  const entry = obj as Record<string, unknown>;
508
+ // Sidechain entries are a SUBAGENT's conversation recorded in the same
509
+ // JSONL. A subagent prompt is a genuine-looking user-role text entry
510
+ // appended mid-turn, so counting it moves the ordinal within the turn —
511
+ // finding 3's failure class in a new shape (R2 round 2, finding 3).
512
+ if (entry['isSidechain'] === true) continue;
333
513
  const role = entry['type'];
334
514
  if (role !== 'assistant' && role !== 'user') continue;
335
515
  const msg = entry['message'];
336
516
  if (typeof msg !== 'object' || msg === null || Array.isArray(msg)) continue;
337
517
  const text = _messageText((msg as Record<string, unknown>)['content']);
338
518
  if (text === null) continue;
339
- if (role === 'assistant') lastAssistant = text;
340
- else lastUser = text;
519
+ if (role === 'assistant') {
520
+ lastAssistant = text;
521
+ } else if (!isSyntheticPrompt(text)) {
522
+ turnOrdinal += 1;
523
+ }
341
524
  }
342
- return { lastAssistant: lastAssistant.trim(), lastUser: lastUser.trim() };
525
+ return { lastAssistant: lastAssistant.trim(), turnOrdinal };
343
526
  }
344
527
 
345
528
  function _messageText(content: unknown): string | null {
@@ -367,39 +550,52 @@ export interface GateSettings {
367
550
  }
368
551
 
369
552
  /**
370
- * Read the three switches with a line walker rather than the full settings
371
- * cascade: this concern runs on EVERY turn-end and is default-off, so the
372
- * common path must not pay for a cascade load it will discard.
553
+ * YAML spellings a human writes for "on". The `yaml` package parses to the 1.2
554
+ * core schema, where `yes` is the STRING "yes" rather than a boolean — so a
555
+ * maintainer who writes `enabled: yes` means it, and a reader that silently
556
+ * treats it as off is the R2 finding-6b defect wearing a parser instead of a
557
+ * regex. Anything not in this set is off; there is no truthiness guessing.
558
+ */
559
+ function _isOn(v: unknown): boolean | null {
560
+ if (typeof v === 'boolean') return v;
561
+ if (typeof v !== 'string') return null;
562
+ const s = v.trim().toLowerCase();
563
+ if (s === 'true' || s === 'yes' || s === 'on') return true;
564
+ if (s === 'false' || s === 'no' || s === 'off') return false;
565
+ return null;
566
+ }
567
+
568
+ /**
569
+ * Read the three switches through the real settings cascade.
570
+ *
571
+ * The previous version hand-walked `<workspaceRoot>/.agent-settings.yml` for
572
+ * speed, and R2 finding 6 found three defects in that trade: it ignored the
573
+ * user-global layer entirely (so a maintainer who opts in globally got no gate
574
+ * AND no diagnostic), its end-of-line-anchored regex read
575
+ * `enabled: true # soak opt-in` as FALSE, and it accepted a `turn_end_gate:`
576
+ * block under ANY parent as ON because a text walker has no notion of a parent.
577
+ *
578
+ * The stated reason for skipping the cascade does not survive contact with the
579
+ * sibling concerns: `delegation-nudge` calls `load_agent_settings` on
580
+ * `user_prompt_submit`, i.e. every turn. Correctness on a gate that can refuse
581
+ * a turn-end outranks one settings read per turn-end.
373
582
  */
374
583
  export function readGateSettings(workspaceRoot: string): GateSettings {
375
584
  const off: GateSettings = { enabled: false, promissory: true, language: true };
376
- let raw: string;
585
+ let settings: Record<string, unknown>;
377
586
  try {
378
- raw = fs.readFileSync(path.join(workspaceRoot, '.agent-settings.yml'), 'utf-8');
587
+ settings = load_agent_settings({ cwd: workspaceRoot }) as Record<string, unknown>;
379
588
  } catch {
380
589
  return off;
381
590
  }
382
- // Indent-aware: the block ends at the first line indented no deeper than
383
- // the header. A width-agnostic `^\S` terminator would swallow the next
384
- // sibling key and read ITS `enabled:` as ours.
385
- const lines = raw.split('\n');
386
- const headerIdx = lines.findIndex((l) => /^\s*turn_end_gate:\s*$/.test(l));
387
- if (headerIdx === -1) return off;
388
- const headerIndent = (lines[headerIdx]!.match(/^\s*/) ?? [''])[0].length;
389
- const body: string[] = [];
390
- for (const line of lines.slice(headerIdx + 1)) {
391
- if (line.trim() === '' || line.trimStart().startsWith('#')) continue;
392
- const indent = (line.match(/^\s*/) ?? [''])[0].length;
393
- if (indent <= headerIndent) break;
394
- body.push(line);
395
- }
591
+ const hooks = settings['hooks'];
592
+ if (typeof hooks !== 'object' || hooks === null || Array.isArray(hooks)) return off;
593
+ const block = (hooks as Record<string, unknown>)['turn_end_gate'];
594
+ if (typeof block !== 'object' || block === null || Array.isArray(block)) return off;
595
+ const gate = block as Record<string, unknown>;
396
596
  const flag = (name: string, dflt: boolean): boolean => {
397
- const re = new RegExp(`^\\s+${name}:\\s*(true|false)\\s*$`);
398
- for (const line of body) {
399
- const m = line.match(re);
400
- if (m) return m[1] === 'true';
401
- }
402
- return dflt;
597
+ const v = _isOn(gate[name]);
598
+ return v === null ? dflt : v;
403
599
  };
404
600
  return {
405
601
  enabled: flag('enabled', false),
@@ -436,23 +632,35 @@ export function main(): number {
436
632
  const transcriptPath = str(
437
633
  (payload['transcript_path'] ?? payload['transcriptPath']) as JsonValue | undefined,
438
634
  );
439
- // `AGENT_CONFIG_TRANSCRIPT_HOME` exists for the test harness only: real
440
- // transcripts live under `~/.claude/projects/**` and production never sets
441
- // it. Without it a test cannot exercise the path-safety check at all,
442
- // which would leave the one security-relevant branch unmeasured.
443
- const homeOverride = process.env['AGENT_CONFIG_TRANSCRIPT_HOME'];
444
- const { lastAssistant, lastUser } = readTranscriptTail(
445
- transcriptPath,
446
- homeOverride ? { homeDir: homeOverride } : {},
447
- );
635
+ // No override of the home-confinement check on the production path. R2
636
+ // finding 13: the previous `AGENT_CONFIG_TRANSCRIPT_HOME` widened the one
637
+ // security-relevant predicate in this hook, and an env var that relaxes a
638
+ // path-confinement check IS a bypass of it, whatever the comment says. The
639
+ // tests set `HOME` on the spawned process instead — `os.homedir()` honours
640
+ // it — so the branch is still exercised with no product-code seam.
641
+ // The cap is passed HERE, at the only production call site. R2 round 2,
642
+ // finding 7: enforcing it inside the function while `main()` called
643
+ // `readTranscriptTail(transcriptPath)` with no options left the guard dead
644
+ // in production while its comment claimed it was enforced — the same
645
+ // fixed-the-definition-not-the-caller shape as two other findings in that
646
+ // round, which is why this line exists rather than a default parameter.
647
+ const { lastAssistant, turnOrdinal } = readTranscriptTail(transcriptPath, {
648
+ maxBytes: TRANSCRIPT_READ_MAX_BYTES,
649
+ });
448
650
  if (!lastAssistant) return EXIT_ALLOW;
449
651
 
450
- // Layer 2 — the turn-keyed marker. Reply-independent by construction.
451
- const turnKey = deriveTurnKey(
652
+ // Layer 2 — keyed on the turn's ORDINAL, never on the prompt's text.
653
+ // A host that sends no `session_id` shares one bucket AND one small-integer
654
+ // ordinal namespace, so an unrelated session whose ordinal matches a stored
655
+ // `refused_turn` reads as already-refused and goes unguarded. R2 round 2,
656
+ // finding 16: the sibling hook documents its own bucket as degrading rather
657
+ // than colliding, and this one collides. It is named here rather than
658
+ // papered over — the degradation is toward UNDER-refusing, which is the safe
659
+ // direction, and layer 1 still covers the host that sends the flag.
660
+ const sessionKey = deriveSessionKey(
452
661
  str(envelope['session_id'] as JsonValue | undefined) || 'unknown-session',
453
- lastUser,
454
662
  );
455
- if (alreadyRefusedThisTurn(workspaceRoot, turnKey)) return EXIT_ALLOW;
663
+ if (alreadyRefusedTurn(workspaceRoot, sessionKey, turnOrdinal)) return EXIT_ALLOW;
456
664
 
457
665
  const findings: Finding[] = [];
458
666
  if (settings.promissory) {
@@ -465,7 +673,7 @@ export function main(): number {
465
673
  }
466
674
  if (findings.length === 0) return EXIT_ALLOW;
467
675
 
468
- markRefusedThisTurn(workspaceRoot, turnKey, findings[0]!.detector);
676
+ markRefusedTurn(workspaceRoot, sessionKey, turnOrdinal, findings[0]!.detector);
469
677
 
470
678
  const lines = findings.map(
471
679
  (f) => ` · ${f.detector}: ${f.reason}\n evidence: ${JSON.stringify(f.evidence)}`,