@event4u/agent-config 9.28.0 → 9.30.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 (209) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/AGENTS.md +6 -6
  3. package/CHANGELOG.md +172 -5
  4. package/README.md +2 -1
  5. package/agents/templates/.ai-council.yml.example +55 -24
  6. package/dist/agent-src/commands/analyze/inbox.md +22 -1
  7. package/dist/agent-src/commands/cost/report.md +2 -2
  8. package/dist/agent-src/commands/explain-run.md +31 -1
  9. package/dist/agent-src/commands/team/adversarial.md +8 -7
  10. package/dist/agent-src/commands/team/delegate.md +14 -12
  11. package/dist/agent-src/commands/team/review.md +9 -8
  12. package/dist/agent-src/commands/team/status.md +9 -8
  13. package/dist/agent-src/commands/team.md +29 -19
  14. package/dist/agent-src/contexts/execution/auto-dispatch-classification.md +88 -2
  15. package/dist/agent-src/contexts/execution/auto-orchestration-activation.md +29 -21
  16. package/dist/agent-src/contexts/execution/host-capability-manifest.md +26 -15
  17. package/dist/agent-src/contexts/execution/orchestration-telemetry.md +22 -1
  18. package/dist/agent-src/contexts/execution/roadmap-process-loop.md +17 -0
  19. package/dist/agent-src/contexts/execution/subagent-response-contract.md +65 -1
  20. package/dist/agent-src/contexts/execution/subagent-routing.md +13 -11
  21. package/dist/agent-src/contexts/execution/subagent-spawn-contract.md +25 -0
  22. package/dist/agent-src/contexts/execution/subagent-steering.md +6 -3
  23. package/dist/agent-src/rules/delegation-policy.md +11 -7
  24. package/dist/agent-src/rules/preservation-guard.md +9 -0
  25. package/dist/agent-src/rules/settings-ask-protocol.md +8 -7
  26. package/dist/agent-src/rules/token-budget-discipline.md +1 -1
  27. package/dist/agent-src/scripts/roadmap_gates.ts +312 -0
  28. package/dist/agent-src/scripts/update_roadmap_progress.ts +72 -4
  29. package/dist/agent-src/skills/forensics-report/SKILL.md +114 -0
  30. package/dist/agent-src/skills/forensics-report/evals/triggers.json +17 -0
  31. package/dist/agent-src/skills/premortem/SKILL.md +30 -1
  32. package/dist/agent-src/skills/reasoning-orchestrator/SKILL.md +2 -1
  33. package/dist/agent-src/skills/subagent-orchestration/SKILL.md +17 -5
  34. package/dist/agent-src/skills/subagent-orchestration/schemas/subagent-status.json +61 -3
  35. package/dist/agent-src/skills/test-case-discovery/SKILL.md +1 -1
  36. package/dist/agent-src/templates/AGENTS.md +4 -4
  37. package/dist/agent-src/templates/agents/agent-project-settings.example.yml +1 -1
  38. package/dist/agent-src/templates/roadmaps.md +23 -0
  39. package/dist/cli/registry.js +2 -0
  40. package/dist/cli/registry.js.map +1 -1
  41. package/dist/cli-delegate/{chunk-22GCIJ2O.js → chunk-66OVWGB5.js} +1 -1
  42. package/dist/cli-delegate/{chunk-7OMQCH4V.js → chunk-BTJNRUX3.js} +22 -35
  43. package/dist/cli-delegate/{chunk-DVRS2KVR.js → chunk-DDUNNFYX.js} +24 -17
  44. package/dist/cli-delegate/{chunk-PQSAQKHW.js → chunk-EQY4FLJB.js} +3 -3
  45. package/dist/cli-delegate/{chunk-2BE5I2MN.js → chunk-IYTGIG2Y.js} +155 -91
  46. package/dist/cli-delegate/{chunk-GB2FSUEE.js → chunk-LCFJIQ4J.js} +32 -0
  47. package/dist/cli-delegate/{chunk-VSYT7OHO.js → chunk-LVRYBKTI.js} +31 -2
  48. package/dist/cli-delegate/chunk-MMJL4TIX.js +18 -0
  49. package/dist/cli-delegate/{chunk-JXARCGVN.js → chunk-MP3JT3IU.js} +1 -1
  50. package/dist/cli-delegate/chunk-O3CO5BFB.js +161 -0
  51. package/dist/cli-delegate/{chunk-REKCMYOU.js → chunk-PFBV6XRT.js} +36 -20
  52. package/dist/cli-delegate/chunk-RVGBH4XK.js +142 -0
  53. package/dist/cli-delegate/cmd_analyze_session.js +1 -1
  54. package/dist/cli-delegate/cmd_brand_status.js +1 -1
  55. package/dist/cli-delegate/cmd_conformance.js +2803 -137
  56. package/dist/cli-delegate/cmd_doctor.js +10 -5
  57. package/dist/cli-delegate/cmd_explain.js +2 -2
  58. package/dist/cli-delegate/cmd_export.js +4 -4
  59. package/dist/cli-delegate/cmd_handoff.js +88 -8
  60. package/dist/cli-delegate/cmd_mcp_available.js +1 -1
  61. package/dist/cli-delegate/cmd_migrate.js +2 -2
  62. package/dist/cli-delegate/cmd_packs_active.js +2 -2
  63. package/dist/cli-delegate/cmd_prune.js +4 -4
  64. package/dist/cli-delegate/cmd_route_audit.js +3 -2
  65. package/dist/cli-delegate/cmd_route_explain.js +1 -1
  66. package/dist/cli-delegate/cmd_session_recycle.js +177 -0
  67. package/dist/cli-delegate/cmd_settings_get.js +2 -8
  68. package/dist/cli-delegate/cmd_settings_migrate.js +4 -4
  69. package/dist/cli-delegate/cmd_settings_set.js +2 -2
  70. package/dist/cli-delegate/cmd_sync.js +4 -4
  71. package/dist/cli-delegate/cmd_uninstall.js +4 -4
  72. package/dist/cli-delegate/cmd_update.js +1 -1
  73. package/dist/cli-delegate/cmd_upgrade.js +1 -1
  74. package/dist/cli-delegate/cmd_validate.js +4 -4
  75. package/dist/cli-delegate/cmd_versions.js +1 -1
  76. package/dist/discovery/deprecation-report.md +1 -1
  77. package/dist/discovery/discovery-manifest.json +72 -23
  78. package/dist/discovery/discovery-manifest.json.sha256 +1 -1
  79. package/dist/discovery/discovery-manifest.summary.md +3 -2
  80. package/dist/discovery/orphan-report.md +1 -1
  81. package/dist/discovery/packs.json +39 -2
  82. package/dist/discovery/trust-report.md +2 -2
  83. package/dist/discovery/workspaces.json +12 -4
  84. package/dist/hooks/dispatch.js +2278 -611
  85. package/dist/install/install.mjs +56 -17
  86. package/dist/mcp/registry-manifest.json +3 -3
  87. package/dist/mcp/server.json +2 -2
  88. package/dist/mcp/server.mjs +4122 -699
  89. package/dist/router.json +1 -1
  90. package/dist/scripts/_lib/agent_settings.js +54 -0
  91. package/dist/scripts/_lib/agent_settings.js.map +1 -1
  92. package/dist/scripts/condense.js +147 -2
  93. package/dist/scripts/condense.js.map +1 -1
  94. package/dist/server/schemas/settings.js +12 -7
  95. package/dist/server/schemas/settings.js.map +1 -1
  96. package/dist/shared/settingsCarveOut.js +0 -9
  97. package/dist/shared/settingsCarveOut.js.map +1 -1
  98. package/dist/shared/settingsClasses.js +11 -9
  99. package/dist/shared/settingsClasses.js.map +1 -1
  100. package/dist/ui/assets/index-B5qGYOJo.js +26 -0
  101. package/dist/ui/assets/index-B5qGYOJo.js.map +1 -0
  102. package/dist/ui/index.html +1 -1
  103. package/docs/contracts/settings-classes.md +58 -12
  104. package/package.json +1 -1
  105. package/src/agent-src/scripts/roadmap_gates.ts +312 -0
  106. package/src/agent-src/scripts/update_roadmap_progress.ts +72 -4
  107. package/src/agent-src/templates/AGENTS.md +4 -4
  108. package/src/config/agent-settings.template.yml +88 -66
  109. package/src/config/discovery/packs.yml +11 -0
  110. package/src/config/discovery/workspaces.yml +1 -1
  111. package/src/config/dispatch-economy-metrics.json +61 -0
  112. package/src/config/evaluator-budgets.json +4 -4
  113. package/src/config/gate-violation-baselines.json +3 -3
  114. package/src/config/hook-token-budget.json +91 -0
  115. package/src/config/recycle-threshold-budget.json +12 -0
  116. package/src/config/rule-enforcement-baseline.json +86 -0
  117. package/src/scripts/_cli/cmd_conformance.ts +31 -44
  118. package/src/scripts/_cli/cmd_doctor.ts +65 -48
  119. package/src/scripts/_cli/cmd_session_recycle.ts +226 -0
  120. package/src/scripts/_dispatch.bash +23 -0
  121. package/src/scripts/_lib/agent_settings.ts +58 -0
  122. package/src/scripts/_lib/auto_dispatch.corpus.test.ts +1 -1
  123. package/src/scripts/_lib/auto_dispatch.ts +23 -18
  124. package/src/scripts/_lib/capsule_trigger.ts +124 -0
  125. package/src/scripts/_lib/host_capability.ts +63 -6
  126. package/src/scripts/_lib/judgment_ladder.ts +634 -0
  127. package/src/scripts/_lib/orchestration_gate.ts +8 -0
  128. package/src/scripts/_lib/orchestration_record.ts +78 -10
  129. package/src/scripts/_lib/orchestration_savings.ts +1 -1
  130. package/src/scripts/_lib/recycle_envelope_paths.ts +35 -0
  131. package/src/scripts/_lib/self_repair.ts +354 -13
  132. package/src/scripts/_lib/self_repair_store.ts +28 -0
  133. package/src/scripts/_lib/session_eol.ts +230 -0
  134. package/src/scripts/_lib/session_role.ts +42 -0
  135. package/src/scripts/_lib/subagent_capsule.ts +341 -0
  136. package/src/scripts/_lib/subagent_response.ts +94 -0
  137. package/src/scripts/_lib/subagent_spawn.ts +13 -0
  138. package/src/scripts/_lib/subagent_steering.ts +9 -4
  139. package/src/scripts/_lib/tier_budget_routing.ts +6 -1
  140. package/src/scripts/_lib/worker_budget.ts +37 -2
  141. package/src/scripts/ai_council/clients.ts +126 -55
  142. package/src/scripts/ai_council/config.ts +94 -6
  143. package/src/scripts/ai_council/handoff.ts +113 -0
  144. package/src/scripts/ai_council/modes.ts +19 -4
  145. package/src/scripts/ai_council/orchestrator.ts +105 -0
  146. package/src/scripts/ai_council/quorum.ts +84 -0
  147. package/src/scripts/ai_council/session.ts +44 -0
  148. package/src/scripts/ai_council/transport_resolver.ts +127 -1
  149. package/src/scripts/ai_team/availability.ts +110 -0
  150. package/src/scripts/ai_team/config.ts +34 -14
  151. package/src/scripts/ai_team/team_dispatch.ts +71 -29
  152. package/src/scripts/ask_transport.ts +221 -0
  153. package/src/scripts/bench_critic_protocol.ts +396 -0
  154. package/src/scripts/bench_hook_injection.ts +234 -0
  155. package/src/scripts/check_adr_frontmatter.ts +84 -40
  156. package/src/scripts/check_branch_freshness.ts +1 -0
  157. package/src/scripts/check_corpus_staleness.ts +18 -4
  158. package/src/scripts/check_detector_corpus.ts +219 -0
  159. package/src/scripts/check_gitignore_freshness.ts +42 -8
  160. package/src/scripts/check_hook_bundle_freshness.ts +24 -1
  161. package/src/scripts/check_kernel_prefix_stability.ts +1 -0
  162. package/src/scripts/check_standing_rule_delivery.ts +73 -32
  163. package/src/scripts/check_static_layer_stability.ts +154 -0
  164. package/src/scripts/check_suppression_hygiene.ts +48 -0
  165. package/src/scripts/condense.ts +162 -2
  166. package/src/scripts/council_cli.ts +401 -25
  167. package/src/scripts/dispatch_economy_report.ts +448 -0
  168. package/src/scripts/dispatch_r2_reviewer.ts +15 -2
  169. package/src/scripts/explain_run.ts +206 -4
  170. package/src/scripts/forensics_report.ts +333 -0
  171. package/src/scripts/generate_settings_reference.ts +1 -0
  172. package/src/scripts/handoff_context_hook.ts +112 -4
  173. package/src/scripts/hook_manifest.yaml +117 -10
  174. package/src/scripts/hooks/concern_registry.ts +8 -0
  175. package/src/scripts/hooks/delegation_nudge_hook.ts +106 -35
  176. package/src/scripts/hooks/dispatch_hook.ts +39 -2
  177. package/src/scripts/hooks/edit_shape_hook.ts +303 -0
  178. package/src/scripts/hooks/orchestration_record_hook.ts +19 -1
  179. package/src/scripts/hooks/reread_guard_hook.ts +314 -0
  180. package/src/scripts/hooks/rtk_wrap_hook.ts +133 -14
  181. package/src/scripts/hooks/session_eol_hook.ts +268 -0
  182. package/src/scripts/hooks/turn_end_gate_hook.ts +507 -0
  183. package/src/scripts/install-hooks.sh +1 -1
  184. package/src/scripts/language_mirror_hook.ts +89 -0
  185. package/src/scripts/lint_decision_impact.ts +14 -1
  186. package/src/scripts/lint_discovery_vocabulary.ts +2 -0
  187. package/src/scripts/lint_hook_manifest.ts +82 -0
  188. package/src/scripts/lint_never_silent.ts +276 -0
  189. package/src/scripts/lint_no_activation_gates.ts +271 -0
  190. package/src/scripts/lint_readme_serial_comma.ts +1 -0
  191. package/src/scripts/lint_rule_enforcement_declaration.ts +347 -0
  192. package/src/scripts/measure_turn_end_gate.ts +194 -0
  193. package/src/scripts/orchestration_record.ts +12 -1
  194. package/src/scripts/rebuild_hook_bundle.ts +178 -0
  195. package/src/scripts/release.ts +74 -2
  196. package/src/scripts/report_conformance_funnel.ts +284 -0
  197. package/src/scripts/report_imperative_density.ts +1 -0
  198. package/src/scripts/routing_doctor.ts +118 -36
  199. package/src/scripts/score_skill_selection.ts +19 -5
  200. package/src/scripts/self_repair_cli.ts +337 -59
  201. package/src/scripts/session_eol_report.ts +261 -0
  202. package/src/scripts/team_events_spike.ts +271 -0
  203. package/src/scripts/team_review_gate_hook.ts +31 -9
  204. package/src/server/schemas/settings.ts +24 -17
  205. package/src/shared/settingsCarveOut.ts +0 -10
  206. package/src/shared/settingsClasses.ts +11 -9
  207. package/src/templates/compact-instructions.md +40 -0
  208. package/dist/ui/assets/index-DmR0mbk4.js +0 -26
  209. package/dist/ui/assets/index-DmR0mbk4.js.map +0 -1
@@ -0,0 +1,507 @@
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 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.
36
+ *
37
+ * The failure this ordering prevents is not a loop but a wedge: a turn that
38
+ * can never end. Layer 2 also covers the host that does not send
39
+ * `stop_hook_active` at all.
40
+ *
41
+ * ## Default OFF — and why, given the roadmap says otherwise
42
+ *
43
+ * `road-to-conformance-round5.md` § 3.6 asks for a kill-switch per
44
+ * detector, "default on". The council recorded in round 6 (2026-08-08)
45
+ * asked for the mechanism to be opt-in, "defaulting to off, so the
46
+ * mechanism exists and soaks before it binds" — two prior hook-severity
47
+ * mistakes plus a turn-end blast radius made default-on uninsurable in
48
+ * their reading. Both are satisfied literally: the MASTER switch
49
+ * (`hooks.turn_end_gate.enabled`) is default OFF, and WITHIN it both
50
+ * detectors are default ON. A maintainer who opts in gets both without
51
+ * further configuration; until then this concern is a no-op that costs one
52
+ * settings read.
53
+ *
54
+ * CONTRACT: dispatcher-internal exit is 1 (EXIT_BLOCK) on a fire, 0
55
+ * otherwise. `fail_closed: false` — deliberately. A crash in a turn-end
56
+ * gate must resolve to "let the turn end", never to a wedge; promoting a
57
+ * crash to a block is the outage this whole file is arranged to avoid.
58
+ * Every unreadable input, missing transcript, absent pin, or malformed
59
+ * state resolves to silence.
60
+ */
61
+ import * as crypto from 'node:crypto';
62
+ import * as fs from 'node:fs';
63
+ import * as path from 'node:path';
64
+ import { fileURLToPath, pathToFileURL } from 'node:url';
65
+
66
+ import { classify, STATE_FILE as LANGUAGE_STATE_FILE, type Verdict } from '../language_mirror_hook.js';
67
+ import { isSafeTranscriptPath } from './end_review_nudge_hook.js';
68
+ import { unwrap, type JsonObject, type JsonValue } from './envelope.js';
69
+ import { readHookStdin } from './hook_stdin.js';
70
+ import { atomic_write_json, is_replay_mode } from './state_io.js';
71
+
72
+ /** Dispatcher-internal block code. Pinned to 1 by `concern_block_exit_parity`. */
73
+ const EXIT_BLOCK = 1;
74
+ const EXIT_ALLOW = 0;
75
+
76
+ /** Detector identity, used in the state marker and the refusal text. */
77
+ export type DetectorId = 'promissory' | 'language';
78
+
79
+ export interface Finding {
80
+ detector: DetectorId;
81
+ /** The span that triggered it — quoted back so the refusal is actionable. */
82
+ evidence: string;
83
+ reason: string;
84
+ }
85
+
86
+ // ---------------------------------------------------------------------------
87
+ // Text extraction — what counts as "user-visible prose"
88
+ // ---------------------------------------------------------------------------
89
+
90
+ /**
91
+ * Strip everything `language-and-tone` already exempts from the mirror
92
+ * obligation: fenced code, inline code, block quotes (quoted tool output),
93
+ * URLs, and bare paths/identifiers. What remains is the prose the rule
94
+ * actually binds.
95
+ *
96
+ * Deliberately conservative — over-stripping costs a missed detection,
97
+ * under-stripping costs a false refusal, and a blocking guard with a false
98
+ * positive rate teaches users to bypass it.
99
+ */
100
+ 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, ' ');
106
+ // Inline code.
107
+ text = text.replace(/`[^`\n]*`/g, ' ');
108
+ // Block quotes — the shape quoted tool output takes.
109
+ text = text.replace(/^[ \t]*>.*$/gm, ' ');
110
+ // Markdown tables — cells are frequently identifiers and paths.
111
+ text = text.replace(/^[ \t]*\|.*\|[ \t]*$/gm, ' ');
112
+ // URLs.
113
+ text = text.replace(/\bhttps?:\/\/\S+/g, ' ');
114
+ // Path-shaped and identifier-shaped tokens.
115
+ text = text.replace(/\S*\/\S+/g, ' ');
116
+ text = text.replace(/\b[\w.-]+\.(ts|js|md|json|ya?ml|py|php|txt|sh)\b/g, ' ');
117
+ return text;
118
+ }
119
+
120
+ /** The final user-visible paragraph — where a promissory closing lives. */
121
+ export function finalParagraph(reply: string): string {
122
+ const prose = visibleProse(reply).trim();
123
+ if (!prose) return '';
124
+ const paragraphs = prose
125
+ .split(/\n\s*\n/)
126
+ .map((p) => p.trim())
127
+ .filter((p) => p.length > 0);
128
+ return paragraphs.length === 0 ? '' : paragraphs[paragraphs.length - 1]!;
129
+ }
130
+
131
+ // ---------------------------------------------------------------------------
132
+ // Detector A — promissory closing
133
+ // ---------------------------------------------------------------------------
134
+
135
+ /**
136
+ * A commitment to work not yet performed. Both languages, because the
137
+ * measured corpus is bilingual and a German-only list would have caught
138
+ * roughly half of the 20.
139
+ */
140
+ const PROMISSORY = [
141
+ /\bich melde mich\b/i,
142
+ /\bmelde ich (mich|dir|Dir)\b/i,
143
+ /\bich melde\b/i,
144
+ /\bich berichte\b/i,
145
+ /\bich (sage|gebe) (dir |Dir )?Bescheid\b/i,
146
+ /\bals n(ä|ae)chstes (werde|mache|baue|pr(ü|ue)fe) ich\b/i,
147
+ // German puts the infinitive at the END of the clause ("ich werde jetzt
148
+ // 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,
153
+ /\bI(?:'| wi)ll (report|let you know|update you|follow up)\b/i,
154
+ /\bI(?:'| wi)ll (now |then )?\w+ (it|that|this|next)\b/i,
155
+ /\bnext,? I(?:'| wi)ll\b/i,
156
+ /\bI am going to\b/i,
157
+ ];
158
+
159
+ /**
160
+ * A legitimate hand-back is the opposite speech act: it gives the decision
161
+ * to the user and ends the turn on purpose. `scope-control` requires
162
+ * exactly this shape, so refusing it would put two rules in direct conflict.
163
+ */
164
+ const HANDBACK = [
165
+ /\bdas entscheidest (du|Du)\b/i,
166
+ /\bdeine Entscheidung\b/i,
167
+ /\bich fasse .{0,40}nicht ungefragt an\b/i,
168
+ /\bsag (Bescheid|ein Wort)\b/i,
169
+ /\bwarte auf (deine|Deine|dein|Dein)\b/i,
170
+ /\byour call\b/i,
171
+ /\byou decide\b/i,
172
+ /\blet me know (which|if|whether)\b/i,
173
+ /\bwaiting for your\b/i,
174
+ ];
175
+
176
+ /**
177
+ * Fires when the FINAL paragraph promises work, the turn hands nothing
178
+ * back, and no question is put to the user. All three, because the
179
+ * measured false-positive shapes are exactly the ones that fail one of them.
180
+ */
181
+ export function detectPromissory(reply: string): Finding | null {
182
+ const tail = finalParagraph(reply);
183
+ if (!tail) return null;
184
+
185
+ // A blocking question IS the stop condition — never refuse one.
186
+ if (tail.includes('?')) return null;
187
+ if (HANDBACK.some((re) => re.test(tail))) return null;
188
+
189
+ for (const re of PROMISSORY) {
190
+ const m = tail.match(re);
191
+ if (m) {
192
+ return {
193
+ detector: 'promissory',
194
+ evidence: m[0],
195
+ reason:
196
+ 'the closing paragraph promises work that has not been performed, ' +
197
+ 'and the turn asks the user nothing — so nothing ends this turn but the promise itself',
198
+ };
199
+ }
200
+ }
201
+ return null;
202
+ }
203
+
204
+ // ---------------------------------------------------------------------------
205
+ // Detector B — language mismatch
206
+ // ---------------------------------------------------------------------------
207
+
208
+ export interface LanguagePin {
209
+ language: Verdict;
210
+ }
211
+
212
+ /** Read the pin the language-mirror hook wrote. Absent ⇒ no obligation. */
213
+ export function readLanguagePin(workspaceRoot: string): Verdict {
214
+ try {
215
+ const raw = fs.readFileSync(path.join(workspaceRoot, LANGUAGE_STATE_FILE), 'utf-8');
216
+ const decoded: unknown = JSON.parse(raw);
217
+ if (typeof decoded === 'object' && decoded !== null && !Array.isArray(decoded)) {
218
+ const lang = (decoded as Record<string, unknown>)['language'];
219
+ if (lang === 'de' || lang === 'en') return lang;
220
+ }
221
+ } catch {
222
+ // no pin, unreadable pin, malformed pin — all mean "no obligation"
223
+ }
224
+ return 'und';
225
+ }
226
+
227
+ /**
228
+ * Fires only when the classifier is CONFIDENT the prose is the other
229
+ * language. `classify` returns `und` below its marker floor, and `und`
230
+ * never fires — a short reply is not evidence of drift.
231
+ */
232
+ export function detectLanguage(reply: string, pinned: Verdict): Finding | null {
233
+ if (pinned !== 'de' && pinned !== 'en') return null;
234
+ const prose = visibleProse(reply);
235
+ const verdict = classify(prose);
236
+ if (verdict.language === 'und') return null;
237
+ if (verdict.language === pinned) return null;
238
+ return {
239
+ detector: 'language',
240
+ evidence: prose.trim().slice(0, 120),
241
+ 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)',
245
+ };
246
+ }
247
+
248
+ // ---------------------------------------------------------------------------
249
+ // Re-entrancy — the guard, keyed on the turn, not on the reply
250
+ // ---------------------------------------------------------------------------
251
+
252
+ function stateDir(workspaceRoot: string): string {
253
+ return path.join(workspaceRoot, 'agents', 'runtime', 'state', 'turn-end-gate');
254
+ }
255
+
256
+ function stateFile(workspaceRoot: string, turnKey: string): string {
257
+ return path.join(stateDir(workspaceRoot), `${turnKey}.json`);
258
+ }
259
+
260
+ /**
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.
263
+ */
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);
270
+ }
271
+
272
+ export function alreadyRefusedThisTurn(workspaceRoot: string, turnKey: string): boolean {
273
+ try {
274
+ return fs.existsSync(stateFile(workspaceRoot, turnKey));
275
+ } catch {
276
+ return false;
277
+ }
278
+ }
279
+
280
+ function markRefusedThisTurn(workspaceRoot: string, turnKey: string, detector: DetectorId): void {
281
+ if (is_replay_mode()) return;
282
+ try {
283
+ atomic_write_json(stateFile(workspaceRoot, turnKey), {
284
+ refused_at: new Date().toISOString(),
285
+ detector,
286
+ });
287
+ } catch {
288
+ // A state-write failure must never wedge the turn. Losing the marker
289
+ // costs at most one extra refusal; failing closed here would cost the
290
+ // session.
291
+ }
292
+ }
293
+
294
+ // ---------------------------------------------------------------------------
295
+ // Transcript
296
+ // ---------------------------------------------------------------------------
297
+
298
+ export interface TranscriptTail {
299
+ lastAssistant: string;
300
+ lastUser: string;
301
+ }
302
+
303
+ /**
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.
307
+ */
308
+ export function readTranscriptTail(
309
+ transcriptPath: string,
310
+ opts: { homeDir?: string; maxBytes?: number } = {},
311
+ ): TranscriptTail {
312
+ const empty: TranscriptTail = { lastAssistant: '', lastUser: '' };
313
+ if (!transcriptPath || !isSafeTranscriptPath(transcriptPath, opts)) return empty;
314
+ let lines: string[];
315
+ try {
316
+ lines = fs.readFileSync(transcriptPath, 'utf-8').split('\n');
317
+ } catch {
318
+ return empty;
319
+ }
320
+ let lastAssistant = '';
321
+ let lastUser = '';
322
+ for (const rawLine of lines) {
323
+ const line = rawLine.trim();
324
+ if (!line) continue;
325
+ let obj: unknown;
326
+ try {
327
+ obj = JSON.parse(line);
328
+ } catch {
329
+ continue;
330
+ }
331
+ if (typeof obj !== 'object' || obj === null || Array.isArray(obj)) continue;
332
+ const entry = obj as Record<string, unknown>;
333
+ const role = entry['type'];
334
+ if (role !== 'assistant' && role !== 'user') continue;
335
+ const msg = entry['message'];
336
+ if (typeof msg !== 'object' || msg === null || Array.isArray(msg)) continue;
337
+ const text = _messageText((msg as Record<string, unknown>)['content']);
338
+ if (text === null) continue;
339
+ if (role === 'assistant') lastAssistant = text;
340
+ else lastUser = text;
341
+ }
342
+ return { lastAssistant: lastAssistant.trim(), lastUser: lastUser.trim() };
343
+ }
344
+
345
+ function _messageText(content: unknown): string | null {
346
+ if (typeof content === 'string') return content;
347
+ if (!Array.isArray(content)) return null;
348
+ const parts: string[] = [];
349
+ for (const blk of content) {
350
+ if (typeof blk !== 'object' || blk === null || Array.isArray(blk)) continue;
351
+ const b = blk as Record<string, unknown>;
352
+ if (b['type'] !== 'text') continue;
353
+ const t = b['text'];
354
+ if (typeof t === 'string') parts.push(t);
355
+ }
356
+ return parts.length > 0 ? parts.join('\n') : null;
357
+ }
358
+
359
+ // ---------------------------------------------------------------------------
360
+ // Settings — master OFF, detectors ON within it
361
+ // ---------------------------------------------------------------------------
362
+
363
+ export interface GateSettings {
364
+ enabled: boolean;
365
+ promissory: boolean;
366
+ language: boolean;
367
+ }
368
+
369
+ /**
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.
373
+ */
374
+ export function readGateSettings(workspaceRoot: string): GateSettings {
375
+ const off: GateSettings = { enabled: false, promissory: true, language: true };
376
+ let raw: string;
377
+ try {
378
+ raw = fs.readFileSync(path.join(workspaceRoot, '.agent-settings.yml'), 'utf-8');
379
+ } catch {
380
+ return off;
381
+ }
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
+ }
396
+ 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;
403
+ };
404
+ return {
405
+ enabled: flag('enabled', false),
406
+ promissory: flag('promissory', true),
407
+ language: flag('language', true),
408
+ };
409
+ }
410
+
411
+ // ---------------------------------------------------------------------------
412
+ // main
413
+ // ---------------------------------------------------------------------------
414
+
415
+ function str(v: JsonValue | undefined): string {
416
+ return typeof v === 'string' ? v : '';
417
+ }
418
+
419
+ export function main(): number {
420
+ let envelope: JsonObject;
421
+ let payload: JsonObject;
422
+ try {
423
+ [envelope, payload] = unwrap(readHookStdin(), 'claude');
424
+ } catch {
425
+ return EXIT_ALLOW;
426
+ }
427
+
428
+ // Layer 1 — the host's own answer to "did I already refuse this turn?".
429
+ if (payload['stop_hook_active'] === true) return EXIT_ALLOW;
430
+
431
+ const workspaceRoot = str(envelope['workspace_root'] as JsonValue | undefined) || process.cwd();
432
+
433
+ const settings = readGateSettings(workspaceRoot);
434
+ if (!settings.enabled) return EXIT_ALLOW;
435
+
436
+ const transcriptPath = str(
437
+ (payload['transcript_path'] ?? payload['transcriptPath']) as JsonValue | undefined,
438
+ );
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
+ );
448
+ if (!lastAssistant) return EXIT_ALLOW;
449
+
450
+ // Layer 2 — the turn-keyed marker. Reply-independent by construction.
451
+ const turnKey = deriveTurnKey(
452
+ str(envelope['session_id'] as JsonValue | undefined) || 'unknown-session',
453
+ lastUser,
454
+ );
455
+ if (alreadyRefusedThisTurn(workspaceRoot, turnKey)) return EXIT_ALLOW;
456
+
457
+ const findings: Finding[] = [];
458
+ if (settings.promissory) {
459
+ const f = detectPromissory(lastAssistant);
460
+ if (f) findings.push(f);
461
+ }
462
+ if (settings.language) {
463
+ const f = detectLanguage(lastAssistant, readLanguagePin(workspaceRoot));
464
+ if (f) findings.push(f);
465
+ }
466
+ if (findings.length === 0) return EXIT_ALLOW;
467
+
468
+ markRefusedThisTurn(workspaceRoot, turnKey, findings[0]!.detector);
469
+
470
+ const lines = findings.map(
471
+ (f) => ` · ${f.detector}: ${f.reason}\n evidence: ${JSON.stringify(f.evidence)}`,
472
+ );
473
+ process.stderr.write(
474
+ `turn-end-gate: REFUSED — this turn is not finished.\n${lines.join('\n')}\n` +
475
+ ' Do the promised work now, or correct the language, then end the turn.\n' +
476
+ ' This turn will not be refused a second time.\n' +
477
+ ' Disable: hooks.turn_end_gate.enabled: false in .agent-settings.yml\n',
478
+ );
479
+ return EXIT_BLOCK;
480
+ }
481
+
482
+ // Bundle-safety: never auto-run when inlined into an esbuild bundle, where
483
+ // every module shares the bundle's `import.meta.url`.
484
+ declare const __AGENT_CONFIG_BUNDLE__: boolean | undefined;
485
+ function _isCliEntry(): boolean {
486
+ if (typeof __AGENT_CONFIG_BUNDLE__ !== 'undefined' && __AGENT_CONFIG_BUNDLE__) {
487
+ return false;
488
+ }
489
+ if (process.argv[1] === undefined) {
490
+ return false;
491
+ }
492
+ const argvUrl = pathToFileURL(path.resolve(process.argv[1])).href;
493
+ if (import.meta.url === argvUrl) {
494
+ return true;
495
+ }
496
+ try {
497
+ const here = fs.realpathSync(fileURLToPath(import.meta.url));
498
+ const argv = fs.realpathSync(path.resolve(process.argv[1]));
499
+ return here === argv;
500
+ } catch {
501
+ return false;
502
+ }
503
+ }
504
+
505
+ if (_isCliEntry()) {
506
+ process.exit(main());
507
+ }
@@ -145,7 +145,7 @@ fi
145
145
  # Roadmap dashboard sync — only fires when staged changes touch a roadmap
146
146
  # file or the dashboard itself, so unrelated commits stay fast.
147
147
  if git diff --cached --name-only | grep -qE '^agents/roadmaps(-progress\.md|/)'; then
148
- ./scripts-run .augment/scripts/update_roadmap_progress --check
148
+ ./scripts-run src/scripts/roadmap_progress_hook --check
149
149
  rstatus=$?
150
150
  if [ $rstatus -ne 0 ]; then
151
151
  # The check fails for THREE distinct reasons and prints the real one
@@ -87,6 +87,13 @@ const EXIT_WARN = 2;
87
87
 
88
88
  export const STATE_FILE = path.join("agents", "state", "language-mirror.json");
89
89
 
90
+ /**
91
+ * Marker written at `pre_compact` and cleared by the single `post_tool_use`
92
+ * re-emit that follows it (round-5 § 6.1). Its existence — not a counter — is
93
+ * what bounds the re-injection to ONE per compaction event.
94
+ */
95
+ export const PIN_LOST_MARKER = path.join("agents", "state", "language-mirror.pin-lost");
96
+
90
97
  type JsonValue = string | number | boolean | null | JsonValue[] | { [k: string]: JsonValue };
91
98
  type JsonObject = { [k: string]: JsonValue };
92
99
 
@@ -380,6 +387,64 @@ function _extractPrompt(payload: JsonObject): string {
380
387
  return "";
381
388
  }
382
389
 
390
+ /** Record that compaction is about to drop the context copy of the pin. */
391
+ export function _setPinLost(consumer_root: string): void {
392
+ 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());
396
+ } catch {
397
+ // Observability only — a failed marker write costs one un-restored pin,
398
+ // never a broken turn.
399
+ }
400
+ }
401
+
402
+ export function _pinLost(consumer_root: string): boolean {
403
+ try {
404
+ return fs.existsSync(path.join(consumer_root, PIN_LOST_MARKER));
405
+ } catch {
406
+ return false;
407
+ }
408
+ }
409
+
410
+ function _clearPinLost(consumer_root: string): void {
411
+ try {
412
+ fs.rmSync(path.join(consumer_root, PIN_LOST_MARKER), { force: true });
413
+ } catch {
414
+ // If it cannot be cleared the next tool call re-emits once more. One
415
+ // duplicate line is the failure mode; a loop is not, because the marker
416
+ // is only ever SET by a compaction event.
417
+ }
418
+ }
419
+
420
+ /**
421
+ * The `post_tool_use` half. Emits the pin exactly once per compaction event
422
+ * and only when a pin actually exists — an absent or undetermined pin is not
423
+ * an obligation, and inventing one here would be worse than the gap.
424
+ */
425
+ export function _reEmitAfterCompaction(consumer_root: string): number {
426
+ if (!_pinLost(consumer_root)) {
427
+ return EXIT_ALLOW;
428
+ }
429
+ const previous = _loadState(path.join(consumer_root, STATE_FILE));
430
+ const language = previous.language as Verdict | undefined;
431
+ const source: PinSource = previous.source === "system-locale" ? "system-locale" : "prompt";
432
+ // Clear FIRST: if the write below throws, the marker must not survive to
433
+ // re-fire on every subsequent tool call — that is the shape § 6.2 refuses.
434
+ _clearPinLost(consumer_root);
435
+ if (language !== "de" && language !== "en") {
436
+ return EXIT_ALLOW;
437
+ }
438
+ process.stdout.write(
439
+ `${JSON.stringify({
440
+ decision: "warn",
441
+ reason: noticeText(language, source),
442
+ additional_context: pinText(language, source),
443
+ })}\n`,
444
+ );
445
+ return EXIT_WARN;
446
+ }
447
+
383
448
  export function run(
384
449
  stdin_text: string,
385
450
  options: { consumer_root: string; env?: NodeJS.ProcessEnv },
@@ -397,6 +462,30 @@ export function run(
397
462
  }
398
463
 
399
464
  const payload = _isObject(envelope["payload"]) ? (envelope["payload"] as JsonObject) : envelope;
465
+
466
+ // round-5 § 6.1 — the compaction boundary.
467
+ //
468
+ // 4 of the 23 post-merge violations happened with NO pin in context: a
469
+ // `compact_boundary` fired and the first violation followed 26 seconds
470
+ // later. Compaction removes the context copy of the pin, not this hook's
471
+ // state file — so this is a state defect with a deterministic fix, aimed
472
+ // only at the subset where the pin was measurably MISSING, never at the 19
473
+ // where it was measurably ignored.
474
+ //
475
+ // The guard IS the design, and is not optional: `pre_compact` sets a
476
+ // marker, `post_tool_use` re-emits once while it is set and clears it. That
477
+ // is one extra injection per compaction EVENT. A re-pin on every tool call
478
+ // is what § 6.2 refuses — "the same failed mechanism running more often" —
479
+ // and this marker is the entire difference between the two.
480
+ const event = typeof envelope["event"] === "string" ? envelope["event"] : "";
481
+ if (event === "pre_compact") {
482
+ _setPinLost(options.consumer_root);
483
+ return EXIT_ALLOW;
484
+ }
485
+ if (event === "post_tool_use") {
486
+ return _reEmitAfterCompaction(options.consumer_root);
487
+ }
488
+
400
489
  const prompt = _extractPrompt(payload);
401
490
  if (!prompt) {
402
491
  return EXIT_ALLOW;
@@ -41,6 +41,7 @@ import * as path from 'node:path';
41
41
  import { fileURLToPath, pathToFileURL } from 'node:url';
42
42
  import { parse as parseYaml } from 'yaml';
43
43
 
44
+ import { GateLedger } from './_lib/gate_ledger.js';
44
45
  import { runGateCli, runSelfTest } from './_lib/gate_self_test.js';
45
46
  import { reportScanned } from './_lib/scan_scope.js';
46
47
 
@@ -188,15 +189,26 @@ export function main(argv?: readonly string[]): number {
188
189
  names = [];
189
190
  }
190
191
 
192
+ // Per-rule completeness: every listed rule reaches exactly one outcome, so
193
+ // the frontmatter-less `continue` below is a named skip, not a silent one.
194
+ const ledger = new GateLedger('lint_decision_impact');
195
+ ledger.plan(names);
191
196
  const violations: Violation[] = [];
192
197
  let scanned = 0;
193
198
  for (const n of names) {
194
199
  const fm = frontmatterOf(fs.readFileSync(path.join(dir, n), 'utf-8'));
195
200
  if (fm === null) {
201
+ ledger.skip(n, 'not_applicable_kind');
196
202
  continue;
197
203
  }
198
204
  scanned += 1;
199
- violations.push(...checkRule(n.slice(0, -3), fm));
205
+ const found = checkRule(n.slice(0, -3), fm);
206
+ violations.push(...found);
207
+ if (found.length > 0) {
208
+ ledger.fail(n, `${String(found.length)} issue(s)`);
209
+ } else {
210
+ ledger.complete(n);
211
+ }
200
212
  }
201
213
 
202
214
  if (violations.length > 0) {
@@ -214,6 +226,7 @@ export function main(argv?: readonly string[]): number {
214
226
  );
215
227
  }
216
228
 
229
+ ledger.report();
217
230
  reportScanned({
218
231
  gate: 'lint_decision_impact',
219
232
  scanned,
@@ -50,6 +50,8 @@ const ADR_PACKS: ReadonlySet<string> = new Set<string>([
50
50
  'legal-review-prep',
51
51
  // road-to-scale-and-history-discipline Phase 1/3 (ADR-013 amendment, same PR).
52
52
  'scale-discipline', 'history-discipline',
53
+ // road-to-judgment-and-forensic-evidence Phase 3 (ADR-013 amendment, same PR).
54
+ 'forensics',
53
55
  ]);
54
56
 
55
57
  // ADR-010 non-overlap reservations.