@event4u/agent-config 9.29.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 (142) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/CHANGELOG.md +32 -214
  3. package/README.md +1 -0
  4. package/dist/agent-src/commands/analyze/inbox.md +22 -1
  5. package/dist/agent-src/commands/explain-run.md +31 -1
  6. package/dist/agent-src/commands/team/delegate.md +1 -0
  7. package/dist/agent-src/contexts/execution/orchestration-telemetry.md +4 -1
  8. package/dist/agent-src/contexts/execution/roadmap-process-loop.md +17 -0
  9. package/dist/agent-src/contexts/execution/subagent-response-contract.md +11 -1
  10. package/dist/agent-src/rules/preservation-guard.md +9 -0
  11. package/dist/agent-src/scripts/roadmap_gates.ts +312 -0
  12. package/dist/agent-src/scripts/update_roadmap_progress.ts +72 -4
  13. package/dist/agent-src/skills/subagent-orchestration/SKILL.md +10 -0
  14. package/dist/agent-src/templates/agents/agent-project-settings.example.yml +1 -1
  15. package/dist/cli/registry.js +2 -0
  16. package/dist/cli/registry.js.map +1 -1
  17. package/dist/cli-delegate/{chunk-RCIA67XN.js → chunk-2WCLVDWC.js} +45 -2
  18. package/dist/cli-delegate/{chunk-66OVWGB5.js → chunk-AG47LAGQ.js} +1 -1
  19. package/dist/cli-delegate/{chunk-MP3JT3IU.js → chunk-DS6BLDMG.js} +1 -1
  20. package/dist/cli-delegate/chunk-EJITJBJX.js +172 -0
  21. package/dist/cli-delegate/{chunk-NZ5N2XQL.js → chunk-ITIQSGOQ.js} +3 -3
  22. package/dist/cli-delegate/{chunk-LCFJIQ4J.js → chunk-JEJABGKB.js} +110 -6
  23. package/dist/cli-delegate/chunk-MMJL4TIX.js +18 -0
  24. package/dist/cli-delegate/{chunk-REKCMYOU.js → chunk-PFBV6XRT.js} +36 -20
  25. package/dist/cli-delegate/{chunk-OJM3MBJM.js → chunk-QEEB4LHO.js} +22 -35
  26. package/dist/cli-delegate/chunk-RVGBH4XK.js +142 -0
  27. package/dist/cli-delegate/{chunk-MMXA5GY7.js → chunk-UVKNIJH2.js} +14 -0
  28. package/dist/cli-delegate/cmd_analyze_session.js +1 -1
  29. package/dist/cli-delegate/cmd_brand_status.js +1 -1
  30. package/dist/cli-delegate/cmd_conformance.js +2861 -96
  31. package/dist/cli-delegate/cmd_doctor.js +3 -2
  32. package/dist/cli-delegate/cmd_explain.js +2 -2
  33. package/dist/cli-delegate/cmd_export.js +4 -4
  34. package/dist/cli-delegate/cmd_handoff.js +87 -7
  35. package/dist/cli-delegate/cmd_mcp_available.js +1 -1
  36. package/dist/cli-delegate/cmd_migrate.js +2 -2
  37. package/dist/cli-delegate/cmd_packs_active.js +2 -2
  38. package/dist/cli-delegate/cmd_prune.js +4 -4
  39. package/dist/cli-delegate/cmd_route_audit.js +3 -2
  40. package/dist/cli-delegate/cmd_route_explain.js +1 -1
  41. package/dist/cli-delegate/cmd_session_recycle.js +177 -0
  42. package/dist/cli-delegate/cmd_settings_get.js +3 -56
  43. package/dist/cli-delegate/cmd_settings_migrate.js +4 -4
  44. package/dist/cli-delegate/cmd_settings_set.js +2 -2
  45. package/dist/cli-delegate/cmd_sync.js +4 -4
  46. package/dist/cli-delegate/cmd_uninstall.js +4 -4
  47. package/dist/cli-delegate/cmd_update.js +1 -1
  48. package/dist/cli-delegate/cmd_upgrade.js +1 -1
  49. package/dist/cli-delegate/cmd_validate.js +4 -4
  50. package/dist/cli-delegate/cmd_versions.js +1 -1
  51. package/dist/discovery/deprecation-report.md +1 -1
  52. package/dist/discovery/discovery-manifest.json +7 -7
  53. package/dist/discovery/discovery-manifest.json.sha256 +1 -1
  54. package/dist/discovery/discovery-manifest.summary.md +1 -1
  55. package/dist/discovery/orphan-report.md +1 -1
  56. package/dist/discovery/packs.json +2 -2
  57. package/dist/discovery/trust-report.md +1 -1
  58. package/dist/discovery/workspaces.json +2 -2
  59. package/dist/hooks/dispatch.js +2143 -641
  60. package/dist/install/install.mjs +133 -14
  61. package/dist/mcp/registry-manifest.json +2 -2
  62. package/dist/mcp/server.json +2 -2
  63. package/dist/mcp/server.mjs +3967 -761
  64. package/dist/scripts/_lib/agent_settings.js +111 -2
  65. package/dist/scripts/_lib/agent_settings.js.map +1 -1
  66. package/dist/server/schemas/settings.js +6 -0
  67. package/dist/server/schemas/settings.js.map +1 -1
  68. package/dist/ui/assets/index-B5qGYOJo.js +26 -0
  69. package/dist/ui/assets/index-B5qGYOJo.js.map +1 -0
  70. package/dist/ui/index.html +1 -1
  71. package/docs/contracts/settings-classes.md +21 -5
  72. package/package.json +1 -1
  73. package/src/agent-src/scripts/roadmap_gates.ts +312 -0
  74. package/src/agent-src/scripts/update_roadmap_progress.ts +72 -4
  75. package/src/config/agent-settings.template.yml +36 -0
  76. package/src/config/dispatch-economy-metrics.json +61 -0
  77. package/src/config/evaluator-budgets.json +4 -4
  78. package/src/config/hook-token-budget.json +91 -0
  79. package/src/config/pack-size-budget.json +3 -2
  80. package/src/config/recycle-threshold-budget.json +12 -0
  81. package/src/config/rule-enforcement-baseline.json +86 -0
  82. package/src/scripts/_cli/cmd_conformance.ts +17 -2
  83. package/src/scripts/_cli/cmd_session_recycle.ts +226 -0
  84. package/src/scripts/_cli/explain_last/inputs.ts +13 -0
  85. package/src/scripts/_dispatch.bash +23 -0
  86. package/src/scripts/_lib/agent_settings.ts +119 -2
  87. package/src/scripts/_lib/carrier_divergence.ts +47 -0
  88. package/src/scripts/_lib/judgment_ladder.ts +254 -2
  89. package/src/scripts/_lib/orchestration_record.ts +41 -10
  90. package/src/scripts/_lib/recycle_envelope_paths.ts +35 -0
  91. package/src/scripts/_lib/self_repair.ts +194 -0
  92. package/src/scripts/_lib/session_eol.ts +230 -0
  93. package/src/scripts/_lib/session_role.ts +42 -0
  94. package/src/scripts/_lib/subagent_capsule.ts +159 -0
  95. package/src/scripts/_lib/subagent_response.ts +75 -0
  96. package/src/scripts/ai_council/clients.ts +50 -1
  97. package/src/scripts/ask_transport.ts +221 -0
  98. package/src/scripts/bench_hook_injection.ts +234 -0
  99. package/src/scripts/check_adr_frontmatter.ts +84 -40
  100. package/src/scripts/check_branch_freshness.ts +1 -0
  101. package/src/scripts/check_corpus_staleness.ts +18 -4
  102. package/src/scripts/check_detector_corpus.ts +17 -0
  103. package/src/scripts/check_gitignore_freshness.ts +42 -8
  104. package/src/scripts/check_hook_bundle_freshness.ts +20 -0
  105. package/src/scripts/check_kernel_prefix_stability.ts +1 -0
  106. package/src/scripts/check_standing_rule_delivery.ts +73 -32
  107. package/src/scripts/check_static_layer_stability.ts +154 -0
  108. package/src/scripts/check_suppression_hygiene.ts +48 -0
  109. package/src/scripts/dispatch_economy_report.ts +448 -0
  110. package/src/scripts/dispatch_r2_reviewer.ts +15 -2
  111. package/src/scripts/explain_run.ts +206 -4
  112. package/src/scripts/generate_settings_reference.ts +1 -0
  113. package/src/scripts/handoff_context_hook.ts +112 -4
  114. package/src/scripts/hook_manifest.yaml +117 -10
  115. package/src/scripts/hooks/concern_registry.ts +8 -0
  116. package/src/scripts/hooks/delegation_nudge_hook.ts +40 -3
  117. package/src/scripts/hooks/dispatch_hook.ts +39 -2
  118. package/src/scripts/hooks/edit_shape_hook.ts +303 -0
  119. package/src/scripts/hooks/orchestration_record_hook.ts +19 -1
  120. package/src/scripts/hooks/reread_guard_hook.ts +314 -0
  121. package/src/scripts/hooks/rtk_wrap_hook.ts +133 -14
  122. package/src/scripts/hooks/session_eol_hook.ts +268 -0
  123. package/src/scripts/hooks/turn_end_gate_hook.ts +715 -0
  124. package/src/scripts/install-hooks.sh +1 -1
  125. package/src/scripts/language_mirror_hook.ts +143 -1
  126. package/src/scripts/lint_decision_impact.ts +14 -1
  127. package/src/scripts/lint_hook_manifest.ts +82 -0
  128. package/src/scripts/lint_readme_serial_comma.ts +1 -0
  129. package/src/scripts/lint_rule_enforcement_declaration.ts +347 -0
  130. package/src/scripts/measure_turn_end_gate.ts +272 -0
  131. package/src/scripts/orchestration_record.ts +4 -1
  132. package/src/scripts/rebuild_hook_bundle.ts +1 -0
  133. package/src/scripts/report_carrier_divergence.ts +72 -24
  134. package/src/scripts/report_conformance_funnel.ts +291 -0
  135. package/src/scripts/report_imperative_density.ts +1 -0
  136. package/src/scripts/routing_doctor.ts +97 -0
  137. package/src/scripts/self_repair_cli.ts +51 -3
  138. package/src/scripts/session_eol_report.ts +261 -0
  139. package/src/server/schemas/settings.ts +14 -0
  140. package/src/templates/compact-instructions.md +40 -0
  141. package/dist/ui/assets/index-JDAF00bC.js +0 -26
  142. package/dist/ui/assets/index-JDAF00bC.js.map +0 -1
@@ -0,0 +1,715 @@
1
+ /**
2
+ * `turn-end-gate` — the suite's FIRST concern that can refuse a turn-end.
3
+ *
4
+ * Round 5 of the conformance audit measured the thing this exists for: the
5
+ * two BLOCKING carriers reached zero violations, neither ADVISORY carrier
6
+ * did, and 19 language violations survived a pin that had fired 26-35
7
+ * seconds earlier. The council read the same split from both sides —
8
+ * "refusal-capable intercepts enforce; context injection requests". So this
9
+ * is deliberately not another reminder. It is a check at the point of
10
+ * delivery that can say no.
11
+ *
12
+ * Two detectors ride on one guard, because building the unsafe part twice
13
+ * is how a second detector becomes a second outage:
14
+ *
15
+ * A — promissory closing (FC-5, 20 measured occurrences)
16
+ * B — language mismatch (19 measured occurrences, fresh pin present)
17
+ *
18
+ * ## Re-entrancy — the shape, stated before registration
19
+ *
20
+ * `road-to-conformance-round6.md` § blocker records the hole this must not
21
+ * fall into: "the re-entrancy guard is specified and unverified. What
22
+ * happens when the refusal *itself* triggers the turn-end event?" Two
23
+ * layers, each sufficient alone:
24
+ *
25
+ * 1. `stop_hook_active` — Claude sets it when a Stop follows a
26
+ * stop-hook block. Set ⇒ this turn was already refused ⇒ allow.
27
+ * Nothing else in `src/` reads this field today; it is the host's own
28
+ * answer to the question above.
29
+ *
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.
47
+ *
48
+ * The failure this ordering prevents is not a loop but a wedge: a turn that
49
+ * can never end. Layer 2 also covers the host that does not send
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.
54
+ *
55
+ * ## Default OFF — and why, given the roadmap says otherwise
56
+ *
57
+ * `road-to-conformance-round5.md` § 3.6 asks for a kill-switch per
58
+ * detector, "default on". The council recorded in round 6 (2026-08-08)
59
+ * asked for the mechanism to be opt-in, "defaulting to off, so the
60
+ * mechanism exists and soaks before it binds" — two prior hook-severity
61
+ * mistakes plus a turn-end blast radius made default-on uninsurable in
62
+ * their reading. Both are satisfied literally: the MASTER switch
63
+ * (`hooks.turn_end_gate.enabled`) is default OFF, and WITHIN it both
64
+ * detectors are default ON. A maintainer who opts in gets both without
65
+ * further configuration; until then this concern is a no-op that costs one
66
+ * settings-cascade read per turn-end.
67
+ *
68
+ * CONTRACT: dispatcher-internal exit is 1 (EXIT_BLOCK) on a fire, 0
69
+ * otherwise. `fail_closed: false` — deliberately. A crash in a turn-end
70
+ * gate must resolve to "let the turn end", never to a wedge; promoting a
71
+ * crash to a block is the outage this whole file is arranged to avoid.
72
+ * Every unreadable input, missing transcript, absent pin, or malformed
73
+ * state resolves to silence.
74
+ */
75
+ import * as crypto from 'node:crypto';
76
+ import * as fs from 'node:fs';
77
+ import * as path from 'node:path';
78
+ import { fileURLToPath, pathToFileURL } from 'node:url';
79
+
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';
87
+ import { isSafeTranscriptPath } from './end_review_nudge_hook.js';
88
+ import { unwrap, type JsonObject, type JsonValue } from './envelope.js';
89
+ import { readHookStdin } from './hook_stdin.js';
90
+ import { atomic_write_json, is_replay_mode } from './state_io.js';
91
+
92
+ /** Dispatcher-internal block code. Pinned to 1 by `concern_block_exit_parity`. */
93
+ const EXIT_BLOCK = 1;
94
+ const EXIT_ALLOW = 0;
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
+
106
+ /** Detector identity, used in the state marker and the refusal text. */
107
+ export type DetectorId = 'promissory' | 'language';
108
+
109
+ export interface Finding {
110
+ detector: DetectorId;
111
+ /** The span that triggered it — quoted back so the refusal is actionable. */
112
+ evidence: string;
113
+ reason: string;
114
+ }
115
+
116
+ // ---------------------------------------------------------------------------
117
+ // Text extraction — what counts as "user-visible prose"
118
+ // ---------------------------------------------------------------------------
119
+
120
+ /**
121
+ * Strip everything `language-and-tone` already exempts from the mirror
122
+ * obligation: fenced code, inline code, block quotes (quoted tool output),
123
+ * URLs, and bare paths/identifiers. What remains is the prose the rule
124
+ * actually binds.
125
+ *
126
+ * Deliberately conservative — over-stripping costs a missed detection,
127
+ * under-stripping costs a false refusal, and a blocking guard with a false
128
+ * positive rate teaches users to bypass it.
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
+
178
+ export function visibleProse(reply: string): string {
179
+ let text = stripFencedBlocks(reply);
180
+ // Inline code.
181
+ text = text.replace(/`[^`\n]*`/g, ' ');
182
+ // Block quotes — the shape quoted tool output takes.
183
+ text = text.replace(/^[ \t]*>.*$/gm, ' ');
184
+ // Markdown tables — cells are frequently identifiers and paths.
185
+ text = text.replace(/^[ \t]*\|.*\|[ \t]*$/gm, ' ');
186
+ // URLs.
187
+ text = text.replace(/\bhttps?:\/\/\S+/g, ' ');
188
+ // Path-shaped and identifier-shaped tokens.
189
+ text = text.replace(/\S*\/\S+/g, ' ');
190
+ text = text.replace(/\b[\w.-]+\.(ts|js|md|json|ya?ml|py|php|txt|sh)\b/g, ' ');
191
+ return text;
192
+ }
193
+
194
+ /** The final user-visible paragraph — where a promissory closing lives. */
195
+ export function finalParagraph(reply: string): string {
196
+ const prose = visibleProse(reply).trim();
197
+ if (!prose) return '';
198
+ const paragraphs = prose
199
+ .split(/\n\s*\n/)
200
+ .map((p) => p.trim())
201
+ .filter((p) => p.length > 0);
202
+ return paragraphs.length === 0 ? '' : paragraphs[paragraphs.length - 1]!;
203
+ }
204
+
205
+ // ---------------------------------------------------------------------------
206
+ // Detector A — promissory closing
207
+ // ---------------------------------------------------------------------------
208
+
209
+ /**
210
+ * A commitment to work not yet performed. Both languages, because the
211
+ * measured corpus is bilingual and a German-only list would have caught
212
+ * roughly half of the 20.
213
+ */
214
+ const PROMISSORY = [
215
+ /\bich melde mich\b/i,
216
+ /\bmelde ich (mich|dir|Dir)\b/i,
217
+ /\bich melde\b/i,
218
+ /\bich berichte\b/i,
219
+ /\bich (sage|gebe) (dir |Dir )?Bescheid\b/i,
220
+ /\bals n(ä|ae)chstes (werde|mache|baue|pr(ü|ue)fe) ich\b/i,
221
+ // German puts the infinitive at the END of the clause ("ich werde jetzt
222
+ // die Tests schreiben"), so the verb cannot be matched adjacent to
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,
245
+ /\bI(?:'| wi)ll (report|let you know|update you|follow up)\b/i,
246
+ /\bI(?:'| wi)ll (now |then )?\w+ (it|that|this|next)\b/i,
247
+ /\bnext,? I(?:'| wi)ll\b/i,
248
+ /\bI am going to\b/i,
249
+ ];
250
+
251
+ /**
252
+ * A legitimate hand-back is the opposite speech act: it gives the decision
253
+ * to the user and ends the turn on purpose. `scope-control` requires
254
+ * exactly this shape, so refusing it would put two rules in direct conflict.
255
+ */
256
+ const HANDBACK = [
257
+ /\bdas entscheidest (du|Du)\b/i,
258
+ /\bdeine Entscheidung\b/i,
259
+ /\bich fasse .{0,40}nicht ungefragt an\b/i,
260
+ /\bsag (Bescheid|ein Wort)\b/i,
261
+ /\bwarte auf (deine|Deine|dein|Dein)\b/i,
262
+ /\byour call\b/i,
263
+ /\byou decide\b/i,
264
+ /\blet me know (which|if|whether)\b/i,
265
+ /\bwaiting for your\b/i,
266
+ ];
267
+
268
+ /**
269
+ * Fires when the FINAL paragraph promises work, the turn hands nothing
270
+ * back, and no question is put to the user. All three, because the
271
+ * measured false-positive shapes are exactly the ones that fail one of them.
272
+ */
273
+ export function detectPromissory(reply: string): Finding | null {
274
+ const tail = finalParagraph(reply);
275
+ if (!tail) return null;
276
+
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;
284
+ if (HANDBACK.some((re) => re.test(tail))) return null;
285
+
286
+ for (const re of PROMISSORY) {
287
+ const m = tail.match(re);
288
+ if (m) {
289
+ return {
290
+ detector: 'promissory',
291
+ evidence: m[0],
292
+ reason:
293
+ 'the closing paragraph promises work that has not been performed, ' +
294
+ 'and the turn asks the user nothing — so nothing ends this turn but the promise itself',
295
+ };
296
+ }
297
+ }
298
+ return null;
299
+ }
300
+
301
+ // ---------------------------------------------------------------------------
302
+ // Detector B — language mismatch
303
+ // ---------------------------------------------------------------------------
304
+
305
+ export interface LanguagePin {
306
+ language: Verdict;
307
+ }
308
+
309
+ /** Read the pin the language-mirror hook wrote. Absent ⇒ no obligation. */
310
+ export function readLanguagePin(workspaceRoot: string): Verdict {
311
+ try {
312
+ const raw = fs.readFileSync(path.join(workspaceRoot, LANGUAGE_STATE_FILE), 'utf-8');
313
+ const decoded: unknown = JSON.parse(raw);
314
+ if (typeof decoded === 'object' && decoded !== null && !Array.isArray(decoded)) {
315
+ const lang = (decoded as Record<string, unknown>)['language'];
316
+ if (lang === 'de' || lang === 'en') return lang;
317
+ }
318
+ } catch {
319
+ // no pin, unreadable pin, malformed pin — all mean "no obligation"
320
+ }
321
+ return 'und';
322
+ }
323
+
324
+ /**
325
+ * Fires only when the classifier is CONFIDENT the prose is the other
326
+ * language. `classify` returns `und` below its marker floor, and `und`
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.
345
+ */
346
+ export function detectLanguage(reply: string, pinned: Verdict): Finding | null {
347
+ if (pinned !== 'de' && pinned !== 'en') return null;
348
+ const prose = visibleProse(reply);
349
+ const verdict = classify(prose);
350
+ if (verdict.language === 'und') return null;
351
+ if (verdict.language === pinned) return null;
352
+ return {
353
+ detector: 'language',
354
+ evidence: prose.trim().slice(0, 120),
355
+ reason:
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)',
360
+ };
361
+ }
362
+
363
+ // ---------------------------------------------------------------------------
364
+ // Re-entrancy — the guard, keyed on the turn, not on the reply
365
+ // ---------------------------------------------------------------------------
366
+
367
+ function stateDir(workspaceRoot: string): string {
368
+ return path.join(workspaceRoot, 'agents', 'runtime', 'state', 'turn-end-gate');
369
+ }
370
+
371
+ /**
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.
382
+ */
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);
389
+ }
390
+
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 {
416
+ try {
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
+ }
422
+ } catch {
423
+ // absent, unreadable, or malformed state means "not refused yet" —
424
+ // fail-open, per this hook's contract.
425
+ }
426
+ return false;
427
+ }
428
+
429
+ function markRefusedTurn(
430
+ workspaceRoot: string,
431
+ sessionKey: string,
432
+ turnOrdinal: number,
433
+ detector: DetectorId,
434
+ ): void {
435
+ if (is_replay_mode()) return;
436
+ try {
437
+ atomic_write_json(sessionStateFile(workspaceRoot, sessionKey), {
438
+ refused_at: new Date().toISOString(),
439
+ refused_turn: turnOrdinal,
440
+ detector,
441
+ });
442
+ } catch {
443
+ // A state-write failure must never wedge the turn. Losing the marker
444
+ // costs at most one extra refusal; failing closed here would cost the
445
+ // session.
446
+ }
447
+ }
448
+
449
+ // ---------------------------------------------------------------------------
450
+ // Transcript
451
+ // ---------------------------------------------------------------------------
452
+
453
+ export interface TranscriptTail {
454
+ lastAssistant: 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;
461
+ }
462
+
463
+ /**
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.
473
+ */
474
+ export function readTranscriptTail(
475
+ transcriptPath: string,
476
+ opts: { homeDir?: string; maxBytes?: number } = {},
477
+ ): TranscriptTail {
478
+ const empty: TranscriptTail = { lastAssistant: '', turnOrdinal: 0 };
479
+ if (!transcriptPath || !isSafeTranscriptPath(transcriptPath, opts)) return empty;
480
+ let lines: string[];
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
+ }
491
+ lines = fs.readFileSync(transcriptPath, 'utf-8').split('\n');
492
+ } catch {
493
+ return empty;
494
+ }
495
+ let lastAssistant = '';
496
+ let turnOrdinal = 0;
497
+ for (const rawLine of lines) {
498
+ const line = rawLine.trim();
499
+ if (!line) continue;
500
+ let obj: unknown;
501
+ try {
502
+ obj = JSON.parse(line);
503
+ } catch {
504
+ continue;
505
+ }
506
+ if (typeof obj !== 'object' || obj === null || Array.isArray(obj)) continue;
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;
513
+ const role = entry['type'];
514
+ if (role !== 'assistant' && role !== 'user') continue;
515
+ const msg = entry['message'];
516
+ if (typeof msg !== 'object' || msg === null || Array.isArray(msg)) continue;
517
+ const text = _messageText((msg as Record<string, unknown>)['content']);
518
+ if (text === null) continue;
519
+ if (role === 'assistant') {
520
+ lastAssistant = text;
521
+ } else if (!isSyntheticPrompt(text)) {
522
+ turnOrdinal += 1;
523
+ }
524
+ }
525
+ return { lastAssistant: lastAssistant.trim(), turnOrdinal };
526
+ }
527
+
528
+ function _messageText(content: unknown): string | null {
529
+ if (typeof content === 'string') return content;
530
+ if (!Array.isArray(content)) return null;
531
+ const parts: string[] = [];
532
+ for (const blk of content) {
533
+ if (typeof blk !== 'object' || blk === null || Array.isArray(blk)) continue;
534
+ const b = blk as Record<string, unknown>;
535
+ if (b['type'] !== 'text') continue;
536
+ const t = b['text'];
537
+ if (typeof t === 'string') parts.push(t);
538
+ }
539
+ return parts.length > 0 ? parts.join('\n') : null;
540
+ }
541
+
542
+ // ---------------------------------------------------------------------------
543
+ // Settings — master OFF, detectors ON within it
544
+ // ---------------------------------------------------------------------------
545
+
546
+ export interface GateSettings {
547
+ enabled: boolean;
548
+ promissory: boolean;
549
+ language: boolean;
550
+ }
551
+
552
+ /**
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.
582
+ */
583
+ export function readGateSettings(workspaceRoot: string): GateSettings {
584
+ const off: GateSettings = { enabled: false, promissory: true, language: true };
585
+ let settings: Record<string, unknown>;
586
+ try {
587
+ settings = load_agent_settings({ cwd: workspaceRoot }) as Record<string, unknown>;
588
+ } catch {
589
+ return off;
590
+ }
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>;
596
+ const flag = (name: string, dflt: boolean): boolean => {
597
+ const v = _isOn(gate[name]);
598
+ return v === null ? dflt : v;
599
+ };
600
+ return {
601
+ enabled: flag('enabled', false),
602
+ promissory: flag('promissory', true),
603
+ language: flag('language', true),
604
+ };
605
+ }
606
+
607
+ // ---------------------------------------------------------------------------
608
+ // main
609
+ // ---------------------------------------------------------------------------
610
+
611
+ function str(v: JsonValue | undefined): string {
612
+ return typeof v === 'string' ? v : '';
613
+ }
614
+
615
+ export function main(): number {
616
+ let envelope: JsonObject;
617
+ let payload: JsonObject;
618
+ try {
619
+ [envelope, payload] = unwrap(readHookStdin(), 'claude');
620
+ } catch {
621
+ return EXIT_ALLOW;
622
+ }
623
+
624
+ // Layer 1 — the host's own answer to "did I already refuse this turn?".
625
+ if (payload['stop_hook_active'] === true) return EXIT_ALLOW;
626
+
627
+ const workspaceRoot = str(envelope['workspace_root'] as JsonValue | undefined) || process.cwd();
628
+
629
+ const settings = readGateSettings(workspaceRoot);
630
+ if (!settings.enabled) return EXIT_ALLOW;
631
+
632
+ const transcriptPath = str(
633
+ (payload['transcript_path'] ?? payload['transcriptPath']) as JsonValue | undefined,
634
+ );
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
+ });
650
+ if (!lastAssistant) return EXIT_ALLOW;
651
+
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(
661
+ str(envelope['session_id'] as JsonValue | undefined) || 'unknown-session',
662
+ );
663
+ if (alreadyRefusedTurn(workspaceRoot, sessionKey, turnOrdinal)) return EXIT_ALLOW;
664
+
665
+ const findings: Finding[] = [];
666
+ if (settings.promissory) {
667
+ const f = detectPromissory(lastAssistant);
668
+ if (f) findings.push(f);
669
+ }
670
+ if (settings.language) {
671
+ const f = detectLanguage(lastAssistant, readLanguagePin(workspaceRoot));
672
+ if (f) findings.push(f);
673
+ }
674
+ if (findings.length === 0) return EXIT_ALLOW;
675
+
676
+ markRefusedTurn(workspaceRoot, sessionKey, turnOrdinal, findings[0]!.detector);
677
+
678
+ const lines = findings.map(
679
+ (f) => ` · ${f.detector}: ${f.reason}\n evidence: ${JSON.stringify(f.evidence)}`,
680
+ );
681
+ process.stderr.write(
682
+ `turn-end-gate: REFUSED — this turn is not finished.\n${lines.join('\n')}\n` +
683
+ ' Do the promised work now, or correct the language, then end the turn.\n' +
684
+ ' This turn will not be refused a second time.\n' +
685
+ ' Disable: hooks.turn_end_gate.enabled: false in .agent-settings.yml\n',
686
+ );
687
+ return EXIT_BLOCK;
688
+ }
689
+
690
+ // Bundle-safety: never auto-run when inlined into an esbuild bundle, where
691
+ // every module shares the bundle's `import.meta.url`.
692
+ declare const __AGENT_CONFIG_BUNDLE__: boolean | undefined;
693
+ function _isCliEntry(): boolean {
694
+ if (typeof __AGENT_CONFIG_BUNDLE__ !== 'undefined' && __AGENT_CONFIG_BUNDLE__) {
695
+ return false;
696
+ }
697
+ if (process.argv[1] === undefined) {
698
+ return false;
699
+ }
700
+ const argvUrl = pathToFileURL(path.resolve(process.argv[1])).href;
701
+ if (import.meta.url === argvUrl) {
702
+ return true;
703
+ }
704
+ try {
705
+ const here = fs.realpathSync(fileURLToPath(import.meta.url));
706
+ const argv = fs.realpathSync(path.resolve(process.argv[1]));
707
+ return here === argv;
708
+ } catch {
709
+ return false;
710
+ }
711
+ }
712
+
713
+ if (_isCliEntry()) {
714
+ process.exit(main());
715
+ }