@event4u/agent-config 14.6.0 → 14.7.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 (188) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/CHANGELOG.md +260 -7
  3. package/README.md +1 -1
  4. package/dist/agent-src/commands/analyze/inbox.md +264 -10
  5. package/dist/agent-src/commands/analyze.md +1 -1
  6. package/dist/agent-src/commands/roadmap/process-full.md +116 -30
  7. package/dist/agent-src/contexts/execution/roadmap-execution-contract.md +31 -0
  8. package/dist/agent-src/contexts/execution/roadmap-process-loop.md +135 -104
  9. package/dist/agent-src/rules/context-hygiene.md +2 -0
  10. package/dist/agent-src/rules/decision-revisit-gate.md +3 -0
  11. package/dist/agent-src/rules/fix-what-you-see.md +108 -0
  12. package/dist/agent-src/rules/no-roadmap-references.md +0 -5
  13. package/dist/agent-src/rules/recurring-criticism.md +110 -0
  14. package/dist/agent-src/rules/roadmap-progress-sync.md +1 -1
  15. package/dist/agent-src/rules/rule-type-governance.md +0 -2
  16. package/dist/agent-src/rules/self-repair-loop.md +17 -0
  17. package/dist/agent-src/rules/skill-quality.md +0 -2
  18. package/dist/agent-src/rules/source-confidentiality.md +37 -7
  19. package/dist/agent-src/scripts/archival_sweep.ts +117 -0
  20. package/dist/agent-src/scripts/archive_completed_roadmaps.ts +19 -3
  21. package/dist/agent-src/scripts/gate_budget.ts +282 -0
  22. package/dist/agent-src/scripts/gate_execute.ts +72 -31
  23. package/dist/agent-src/scripts/roadmap_gates.ts +74 -3
  24. package/dist/agent-src/scripts/update_roadmap_progress.ts +251 -7
  25. package/dist/agent-src/skills/ai-council/SKILL.md +48 -807
  26. package/dist/agent-src/skills/ai-council/references/advanced-modes.md +320 -0
  27. package/dist/agent-src/skills/ai-council/references/cost-and-redaction.md +99 -0
  28. package/dist/agent-src/skills/ai-council/references/execution-modes.md +162 -0
  29. package/dist/agent-src/skills/ai-council/references/output-and-synthesis.md +125 -0
  30. package/dist/agent-src/skills/ai-council/references/procedure.md +143 -0
  31. package/dist/agent-src/skills/quality-tools/SKILL.md +34 -326
  32. package/dist/agent-src/skills/quality-tools/references/js-ts-tools.md +113 -0
  33. package/dist/agent-src/skills/quality-tools/references/php-tools.md +222 -0
  34. package/dist/agent-src/skills/roadmap-management/SKILL.md +15 -409
  35. package/dist/agent-src/skills/roadmap-management/references/archival.md +226 -0
  36. package/dist/agent-src/skills/roadmap-management/references/authoring.md +107 -0
  37. package/dist/agent-src/skills/roadmap-management/references/dashboard.md +47 -0
  38. package/dist/agent-src/skills/roadmap-management/references/lifecycle.md +58 -0
  39. package/dist/agent-src/skills/skill-writing/SKILL.md +34 -570
  40. package/dist/agent-src/skills/skill-writing/references/procedure.md +275 -0
  41. package/dist/agent-src/skills/skill-writing/references/read-only-scripts.md +18 -0
  42. package/dist/agent-src/skills/skill-writing/references/section-patterns.md +298 -0
  43. package/dist/agent-src/templates/agents/agent-project-settings.example.yml +1 -1
  44. package/dist/agent-src/templates/roadmaps.md +45 -2
  45. package/dist/agent-src/templates/scripts/telemetry/flush_sender.mjs +163 -0
  46. package/dist/agent-src/templates/scripts/telemetry/remote.ts +160 -7
  47. package/dist/agent-src/templates/scripts/telemetry/transport.ts +169 -0
  48. package/dist/cli/registry.js +1 -1
  49. package/dist/cli/registry.js.map +1 -1
  50. package/dist/cli-delegate/chunk-2GNWGMAL.js +172 -0
  51. package/dist/cli-delegate/{chunk-RLN73PR5.js → chunk-4VR4ORQ6.js} +10 -7
  52. package/dist/cli-delegate/chunk-6EOCNQAM.js +402 -0
  53. package/dist/cli-delegate/{chunk-XURJ4AQC.js → chunk-74E3TKWV.js} +381 -280
  54. package/dist/cli-delegate/{chunk-3UCJWMMP.js → chunk-7F3VYSM2.js} +4 -4
  55. package/dist/cli-delegate/{chunk-BKO2AZ7Z.js → chunk-B2ERU2VA.js} +1 -1
  56. package/dist/cli-delegate/{chunk-NXFWFAYV.js → chunk-G23AT6GD.js} +1 -0
  57. package/dist/cli-delegate/{chunk-63WSP6YZ.js → chunk-GJ5BVHXM.js} +72 -165
  58. package/dist/cli-delegate/{chunk-P6LQDIWK.js → chunk-JZ4NR2TW.js} +338 -0
  59. package/dist/cli-delegate/chunk-MWRD5YOH.js +100 -0
  60. package/dist/cli-delegate/{chunk-GRRPEPRN.js → chunk-T7L2QK3K.js} +24 -5
  61. package/dist/cli-delegate/{chunk-EVTY7ISR.js → chunk-YM4GZDJG.js} +9 -1
  62. package/dist/cli-delegate/cmd_conformance.js +459 -709
  63. package/dist/cli-delegate/cmd_doctor.js +6 -6
  64. package/dist/cli-delegate/cmd_explain.js +1 -1
  65. package/dist/cli-delegate/cmd_export.js +7 -6
  66. package/dist/cli-delegate/cmd_handoff.js +6 -6
  67. package/dist/cli-delegate/cmd_packs_active.js +1 -1
  68. package/dist/cli-delegate/cmd_preflight.js +2 -2
  69. package/dist/cli-delegate/cmd_prune.js +7 -6
  70. package/dist/cli-delegate/cmd_session_recycle.js +840 -30
  71. package/dist/cli-delegate/cmd_settings_migrate.js +7 -6
  72. package/dist/cli-delegate/cmd_settings_set.js +1 -1
  73. package/dist/cli-delegate/cmd_sync.js +7 -6
  74. package/dist/cli-delegate/cmd_uninstall.js +9 -7
  75. package/dist/cli-delegate/cmd_update.js +1 -1
  76. package/dist/cli-delegate/cmd_upgrade.js +1 -1
  77. package/dist/cli-delegate/cmd_validate.js +7 -6
  78. package/dist/discovery/deprecation-report.md +1 -1
  79. package/dist/discovery/discovery-manifest.json +68 -25
  80. package/dist/discovery/discovery-manifest.json.sha256 +1 -1
  81. package/dist/discovery/discovery-manifest.summary.md +4 -4
  82. package/dist/discovery/dormancy-report.md +1 -1
  83. package/dist/discovery/orphan-report.md +1 -1
  84. package/dist/discovery/packs.json +10 -8
  85. package/dist/discovery/trust-report.md +3 -3
  86. package/dist/discovery/workspaces.json +8 -6
  87. package/dist/hooks/dispatch.js +2901 -2053
  88. package/dist/install/claudePathsPlan.js +187 -0
  89. package/dist/install/claudePathsPlan.js.map +1 -0
  90. package/dist/install/hostLayerFingerprint.js +136 -0
  91. package/dist/install/hostLayerFingerprint.js.map +1 -0
  92. package/dist/install/install.mjs +553 -425
  93. package/dist/install/partitionEligibility.js +276 -0
  94. package/dist/install/partitionEligibility.js.map +1 -0
  95. package/dist/mcp/registry-manifest.json +3 -3
  96. package/dist/mcp/server.json +2 -2
  97. package/dist/mcp/server.mjs +1744 -1191
  98. package/dist/router.json +1 -1
  99. package/dist/scripts/_lib/agent_src.js +41 -0
  100. package/dist/scripts/_lib/agent_src.js.map +1 -1
  101. package/dist/scripts/_lib/install_layout.js +83 -0
  102. package/dist/scripts/_lib/install_layout.js.map +1 -0
  103. package/dist/scripts/_lib/installed_lock.js +444 -0
  104. package/dist/scripts/_lib/installed_lock.js.map +1 -0
  105. package/dist/scripts/condense.js +13 -164
  106. package/dist/scripts/condense.js.map +1 -1
  107. package/dist/server/schemas/settings.js +4 -0
  108. package/dist/server/schemas/settings.js.map +1 -1
  109. package/docs/contracts/settings-classes.md +12 -6
  110. package/docs/guidelines/agent-infra/agent-interaction-and-decision-quality.md +8 -3
  111. package/docs/guidelines/agent-infra/context-hygiene-mechanics.md +26 -0
  112. package/docs/guidelines/agent-infra/memory-access.md +17 -0
  113. package/docs/guidelines/agent-infra/recurring-criticism-mechanics.md +96 -0
  114. package/package.json +1 -1
  115. package/src/agent-src/scripts/archival_sweep.ts +117 -0
  116. package/src/agent-src/scripts/archive_completed_roadmaps.ts +19 -3
  117. package/src/agent-src/scripts/gate_budget.ts +282 -0
  118. package/src/agent-src/scripts/gate_execute.ts +72 -31
  119. package/src/agent-src/scripts/roadmap_gates.ts +74 -3
  120. package/src/agent-src/scripts/update_roadmap_progress.ts +251 -7
  121. package/src/agent-src/templates/scripts/telemetry/flush_sender.mjs +163 -0
  122. package/src/agent-src/templates/scripts/telemetry/remote.ts +160 -7
  123. package/src/agent-src/templates/scripts/telemetry/transport.ts +169 -0
  124. package/src/config/agent-settings.template.yml +27 -2
  125. package/src/config/ci-local-parity.yml +18 -0
  126. package/src/config/estate-count-budget.json +339 -3
  127. package/src/config/gate-violation-baselines.json +41 -41
  128. package/src/config/rule-activation-census.json +14 -6
  129. package/src/config/rule-stub-ceilings.json +19 -6
  130. package/src/install/claudePathsPlan.ts +196 -0
  131. package/src/install/hostLayerFingerprint.ts +145 -0
  132. package/src/install/partitionEligibility.ts +325 -0
  133. package/src/scripts/_cli/cmd_session_recycle.ts +9 -1
  134. package/src/scripts/_dispatch.bash +6 -1
  135. package/src/scripts/_lib/ac_heading.ts +30 -0
  136. package/src/scripts/_lib/agent_src.ts +42 -0
  137. package/src/scripts/_lib/bench_ab_pinned_repo.ts +162 -0
  138. package/src/scripts/_lib/bench_ab_workspace.ts +10 -1
  139. package/src/scripts/_lib/envelope_grounding.ts +38 -8
  140. package/src/scripts/_lib/installed_lock.ts +50 -5
  141. package/src/scripts/_lib/md_section.ts +129 -0
  142. package/src/scripts/_lib/scope_guard.sh +26 -3
  143. package/src/scripts/_lib/self_repair.ts +19 -0
  144. package/src/scripts/_lib/self_repair_class_b.ts +171 -0
  145. package/src/scripts/ai_council/cli_help.ts +55 -0
  146. package/src/scripts/before_complete_hook.ts +153 -28
  147. package/src/scripts/bench_ab_v2_complexity.ts +27 -7
  148. package/src/scripts/check_references.ts +60 -0
  149. package/src/scripts/check_release_published.ts +32 -11
  150. package/src/scripts/check_single_delivery.ts +59 -15
  151. package/src/scripts/ci_settle.ts +170 -0
  152. package/src/scripts/condense.ts +20 -170
  153. package/src/scripts/council_cli.ts +3 -3
  154. package/src/scripts/dispatch_r2_reviewer.ts +6 -14
  155. package/src/scripts/hook_manifest.json +1 -1
  156. package/src/scripts/hook_manifest.yaml +77 -14
  157. package/src/scripts/hooks/block_no_verify.ts +61 -10
  158. package/src/scripts/hooks/block_unauthorized_git.ts +1 -1
  159. package/src/scripts/hooks/concern_registry.ts +4 -0
  160. package/src/scripts/hooks/rtk_wrap_hook.ts +8 -3
  161. package/src/scripts/hooks/run_continuation_hook.ts +8 -5
  162. package/src/scripts/hooks/ship_diff_volume_hook.ts +81 -15
  163. package/src/scripts/hooks/source_first_gate_hook.ts +464 -0
  164. package/src/scripts/hooks/state_io.ts +782 -20
  165. package/src/scripts/hooks/subagent_ledger_hook.ts +44 -15
  166. package/src/scripts/hooks/telemetry_flush_hook.ts +139 -0
  167. package/src/scripts/hooks/telemetry_self_repair.ts +128 -0
  168. package/src/scripts/hooks/telemetry_usage_hook.ts +22 -4
  169. package/src/scripts/hooks/turn_end_gate_hook.ts +81 -15
  170. package/src/scripts/install.ts +16 -11
  171. package/src/scripts/language_mirror_hook.ts +561 -25
  172. package/src/scripts/lint_framework_leakage_allowlist.json +2 -2
  173. package/src/scripts/lint_plan_risk_register.ts +4 -3
  174. package/src/scripts/lint_skill_router_head.ts +4 -4
  175. package/src/scripts/mcp_server/tools.ts +2 -2
  176. package/src/scripts/new_roadmap.ts +136 -0
  177. package/src/scripts/pack_mcp_content.ts +20 -5
  178. package/src/scripts/release.ts +37 -38
  179. package/src/scripts/report_layer_overlap.ts +6 -0
  180. package/src/scripts/self_repair_hook.ts +37 -5
  181. package/src/scripts/session_register_hook.ts +11 -0
  182. package/src/scripts/skill_usage_collect.ts +11 -1
  183. package/src/scripts/skill_usage_report.ts +278 -8
  184. package/src/scripts/sync_pr_branch.ts +7 -0
  185. package/src/server/schemas/settings.ts +8 -0
  186. package/dist/cli-delegate/chunk-EJITJBJX.js +0 -172
  187. package/dist/cli-delegate/chunk-PZWRPRWP.js +0 -38
  188. /package/dist/cli-delegate/{chunk-RYNADUFG.js → chunk-LWTYTX6F.js} +0 -0
@@ -72,7 +72,16 @@ import fs from "node:fs";
72
72
  import path from "node:path";
73
73
  import { fileURLToPath, pathToFileURL } from "node:url";
74
74
 
75
- import { atomic_write_json, atomic_write_text, is_replay_mode } from "./hooks/state_io.js";
75
+ import {
76
+ atomic_write_json,
77
+ atomic_write_text,
78
+ has_stable_session_id,
79
+ is_replay_mode,
80
+ owns_session_state,
81
+ prune_legacy_state_file,
82
+ prune_stale_session_states,
83
+ session_state_file,
84
+ } from "./hooks/state_io.js";
76
85
  import { readHookStdin } from "./hooks/hook_stdin.js";
77
86
  import { humanAuthoredLead, isSyntheticPrompt } from "./_lib/prompt_shape.js";
78
87
 
@@ -85,8 +94,97 @@ const EXIT_ALLOW = 0;
85
94
  // by tracing the delivery rather than re-reading the unit tests.
86
95
  const EXIT_WARN = 2;
87
96
 
97
+ /**
98
+ * The pre-split single-file state. ONE file per project root — and in this
99
+ * repo's worktree workflow `CLAUDE_PROJECT_DIR` resolves to the parent
100
+ * checkout, so every concurrent session shared it. Measured consequence
101
+ * (2026-08-20, session 15b9ac52): a terse German "1" read a neighbouring
102
+ * English session's pin and injected `Reply language for this turn: English.`
103
+ * into a German conversation.
104
+ *
105
+ * Nothing reads it after the per-session split. It is kept exported because
106
+ * `_pruneLegacyState` needs to name the path it deletes, and because a reader
107
+ * that still resolves it should get a compile-visible symbol rather than a
108
+ * silently stale literal.
109
+ *
110
+ * It used to say this "names the path an older bundle may still be writing
111
+ * during an upgrade". That was written without checking and a cross-model review
112
+ * (2026-08-20) built a finding on it: every session under one project root runs
113
+ * ONE dispatcher bundle, resolved through `CLAUDE_PROJECT_DIR` — the parent
114
+ * checkout even in a worktree — so there is no steady mixed-version state. The
115
+ * real, narrow window and its bound are stated once, at
116
+ * `state_io.prune_legacy_state_file`.
117
+ */
88
118
  export const STATE_FILE = path.join("agents", "state", "language-mirror.json");
89
119
 
120
+ /**
121
+ * Per-SESSION state, one file each — the same layout R2 finding 10 gave the
122
+ * pin-lost markers, and for the same reason.
123
+ *
124
+ * A map-in-one-file was the smaller diff and was rejected: two sessions writing
125
+ * concurrently through `atomic_write_json` (write + rename) drop one another's
126
+ * entry, so the cross-talk would return as a lost update instead of a wrong
127
+ * read.
128
+ *
129
+ * This comment used to end "separate files cannot collide by construction", and
130
+ * both council seats named that sentence as false: the first cut derived the
131
+ * filename with a character sanitiser, so `a/b` and `a_b` addressed one file and
132
+ * a substantive prompt from either destroyed the other's state. It is true now
133
+ * only because `statePathFor` keys on a digest of the full id — the property
134
+ * comes from the digest, not from the directory layout, and stating it the other
135
+ * way is how the defect survived a round of review.
136
+ */
137
+ export const STATE_DIR = path.join("agents", "state", "language-mirror");
138
+
139
+ /**
140
+ * Days after which an untouched session's state is pruned.
141
+ *
142
+ * One file per session grows without bound otherwise. 7 matches the retention
143
+ * window this repo already uses for council session artefacts — a convention
144
+ * match, not a measurement, and stated as such.
145
+ */
146
+ export const STATE_RETENTION_DAYS = 7;
147
+
148
+ /**
149
+ * Is there a stable identity to key state on at all?
150
+ *
151
+ * BLOCKER 2, council round 2 (both seats): a sanitised empty id bucketed every
152
+ * id-less invocation into `unknown-session.json`, and `_ownsPin` at the time
153
+ * ACCEPTED an empty stored owner as its own — so id-less sessions shared one
154
+ * file with NO secondary defense. That is the original defect restored, in the
155
+ * one case that had no guard left. (Round 3 closed the second half too:
156
+ * `_ownsPin` now requires exact ownership, so an empty owner is foreign. Stated
157
+ * in the past tense on purpose — describing a fixed defect in the present is
158
+ * how a comment ends up contradicting its own code, which is a round-2 finding
159
+ * against an earlier version of this very file.) There is no sound local way to tell two id-less sessions apart
160
+ * (a fresh UUID per invocation would give one session a new file per hook call,
161
+ * which destroys continuity rather than providing it), so the honest answer is
162
+ * to run stateless: classify the prompt, pin from it, persist nothing.
163
+ */
164
+ export function hasStableSessionId(session_id: string): boolean {
165
+ return has_stable_session_id(session_id);
166
+ }
167
+
168
+ /**
169
+ * Path for one session's pin, keyed by a digest of the FULL id.
170
+ *
171
+ * BLOCKER 1, council round 2: the previous sanitiser mapped `a/b` and `a_b` onto
172
+ * one filename, and `_ownsPin` catches that only AFTER the collision — it
173
+ * refuses to inherit, but it cannot stop a substantive prompt from replacing the
174
+ * other session's file, which loses that session's pin and counter permanently.
175
+ * The seat had already asked for a hashed identifier in round 1 and this code
176
+ * answered with the guard instead; the guard was the wrong answer.
177
+ *
178
+ * A digest also removes the filename-length failure mode for unusually long ids.
179
+ * `_ownsPin` stays as an in-file integrity check — cheap, and it guards against
180
+ * a stale or hand-copied file rather than against a hash collision. Round 3
181
+ * tightened it to exact equality for that role: an ownerless file at a hashed
182
+ * path can only be corruption, since every writer here stamps the owner.
183
+ */
184
+ export function statePathFor(session_id: string): string {
185
+ return session_state_file(STATE_DIR, session_id);
186
+ }
187
+
90
188
  /**
91
189
  * Marker written at `pre_compact` and cleared by the single `post_tool_use`
92
190
  * re-emit that follows it (round-5 § 6.1). Its existence — not a counter — is
@@ -337,6 +435,11 @@ export interface PinState extends JsonObject {
337
435
  de_markers: number;
338
436
  en_markers: number;
339
437
  session_id: string;
438
+ /**
439
+ * Tool calls seen since the pin last reached the model. Optional because
440
+ * every state written before this field existed is still a valid pin.
441
+ */
442
+ tool_calls_since_pin?: number;
340
443
  }
341
444
 
342
445
  function _loadState(target: string): Partial<PinState> {
@@ -349,6 +452,55 @@ function _loadState(target: string): Partial<PinState> {
349
452
  }
350
453
  }
351
454
 
455
+ /**
456
+ * Is a stored pin this session's to inherit? Exact ownership, nothing weaker.
457
+ *
458
+ * This used to accept an absent or empty owner as "legacy state, therefore
459
+ * mine". Council round 3 refuted that for the hashed layout, and the argument
460
+ * is the layout's own: the pre-split shared file is deleted rather than
461
+ * migrated, and every writer into a hashed path sets `session_id` — so a
462
+ * hashed file WITHOUT an owner is corruption or a hand-copy, i.e. exactly the
463
+ * condition this check claims to catch. The permissive branch was dead code
464
+ * that weakened the guarantee in its own doc comment.
465
+ *
466
+ * No compatibility window is owed: the digest path has never shipped, so no
467
+ * deployed version ever wrote an ownerless hashed file.
468
+ *
469
+ * Absent state (`{}`) returns false and that changes nothing at any call site
470
+ * — each one already returns early on a missing `language`, which absent state
471
+ * cannot carry.
472
+ */
473
+ export function _ownsPin(previous: Partial<PinState>, session_id: string): boolean {
474
+ return owns_session_state(previous, session_id);
475
+ }
476
+
477
+ /**
478
+ * Tool calls after which `post_tool_use` re-states the pin once.
479
+ *
480
+ * MEASURED, not chosen (2026-08-20, 10 most recent transcripts, 447 assistant
481
+ * turns following a German prompt): 11 of the 12 English replies sat at
482
+ * 179–200 tool calls since the pin, while compliant turns had p90 = 122 and
483
+ * p99 = 184.
484
+ *
485
+ * 150 lies between compliant p90 (122) and the EARLIEST observed violation
486
+ * (179) — deliberately NOT between the two distributions, because they overlap:
487
+ * compliant p99 = 184 is past the violation floor, so some compliant traffic
488
+ * does cross 150 and will be reminded. That is the accepted cost of margin, and
489
+ * saying otherwise was an overclaim both council seats caught (2026-08-20): the
490
+ * earlier wording here read "never inside the band where compliance is already
491
+ * 100 %", which the p99 refutes. A block of 200 tool calls costs ONE re-emit.
492
+ *
493
+ * HONEST BASIS: n = 11, and they cluster in a single long autonomous session.
494
+ * The distance signal is sharp (0 violations below 179) but the corpus is one
495
+ * session wide, so this is "the number the observed failure sits behind", not
496
+ * a fitted optimum.
497
+ *
498
+ * Revisit-if: a violation is recorded BELOW this distance (the threshold is
499
+ * too high), or a session shows the re-emit firing without any violation
500
+ * having been plausible at that distance across ≥ 5 sessions (too low).
501
+ */
502
+ export const REEMIT_AFTER_TOOL_CALLS = 150;
503
+
352
504
  /**
353
505
  * Decide the next state from the previous one and this turn's classification.
354
506
  * Exported so the tests can assert the keep-previous-pin rule directly.
@@ -362,8 +514,32 @@ export function nextState(
362
514
  locale: Verdict = "und",
363
515
  ): PinState | null {
364
516
  if (classification.language === "und") {
365
- // Terse continuation ("1", "ok", "weiter") — keep whatever was pinned.
366
- if (previous.language === "de" || previous.language === "en") {
517
+ // Terse continuation ("1", "ok", "weiter") — keep whatever was pinned,
518
+ // but ONLY when the pin is this session's own.
519
+ //
520
+ // The state file is ONE file per project root, and in this repo's worktree
521
+ // workflow `CLAUDE_PROJECT_DIR` resolves to the parent checkout — so every
522
+ // concurrent session shares it. Without this check a terse German "3" read
523
+ // a neighbouring English session's pin and injected
524
+ // `Reply language for this turn: English.` into a German conversation
525
+ // (observed 2026-08-20, session 15b9ac52 line 2155). That is the rule
526
+ // inverted, not merely absent, and it is the same cross-session cross-talk
527
+ // R2 finding 10 closed for the pin-lost markers while leaving the language
528
+ // state itself open.
529
+ //
530
+ // An ABSENT `session_id` is FOREIGN, not owned. This comment said the
531
+ // opposite — "treated as owned … only a DIFFERENT id is" — describing the
532
+ // permissive `_ownsPin` that round 3 replaced, and a concurrent session
533
+ // caught it still standing after `owns_session_state` made exact ownership
534
+ // shared. The reasoning it gave (a pre-field state cannot be evidence of
535
+ // another session) no longer applies: every writer into a digest path stamps
536
+ // the owner and that layout never shipped without one, so an unowned file is
537
+ // corruption or a hand-copy, which is precisely what the check is for.
538
+ //
539
+ // Unreachable in practice, and that is why it survived: nothing in the
540
+ // digest layout produces an ownerless file. A comment asserting the inverse
541
+ // of its own code is still a defect — the next reader trusts the prose.
542
+ if (_ownsPin(previous, session_id) && (previous.language === "de" || previous.language === "en")) {
367
543
  return null;
368
544
  }
369
545
  // FIRST turn and nothing to keep: the locale is better than no pin at all.
@@ -378,6 +554,7 @@ export function nextState(
378
554
  de_markers: classification.de_markers,
379
555
  en_markers: classification.en_markers,
380
556
  session_id,
557
+ tool_calls_since_pin: 0,
381
558
  };
382
559
  }
383
560
  return {
@@ -388,6 +565,7 @@ export function nextState(
388
565
  de_markers: classification.de_markers,
389
566
  en_markers: classification.en_markers,
390
567
  session_id,
568
+ tool_calls_since_pin: 0,
391
569
  };
392
570
  }
393
571
 
@@ -475,32 +653,375 @@ function _clearPinLost(consumer_root: string, session_id: string): void {
475
653
  * and only when a pin actually exists — an absent or undetermined pin is not
476
654
  * an obligation, and inventing one here would be worse than the gap.
477
655
  */
478
- export function _reEmitAfterCompaction(consumer_root: string, session_id: string): number {
656
+ export function _reEmitAfterCompaction(
657
+ consumer_root: string,
658
+ session_id: string,
659
+ write_json: StateWriter = atomic_write_json,
660
+ ): number {
661
+ if (!hasStableSessionId(session_id)) {
662
+ return EXIT_ALLOW; // BLOCKER 2 — nothing was persisted, so nothing to restore.
663
+ }
479
664
  if (!_pinLost(consumer_root, session_id)) {
480
665
  return EXIT_ALLOW;
481
666
  }
482
- const previous = _loadState(path.join(consumer_root, STATE_FILE));
483
- const language = previous.language as Verdict | undefined;
484
- const source: PinSource = previous.source === "system-locale" ? "system-locale" : "prompt";
485
- // Clear FIRST: if the write below throws, the marker must not survive to
486
- // re-fire on every subsequent tool call — that is the shape § 6.2 refuses.
487
- _clearPinLost(consumer_root, session_id);
488
- if (language !== "de" && language !== "en") {
667
+
668
+ // Serialised against any peer tool write for this session, and silent when
669
+ // one holds the lock. Everything below reads,
670
+ // decides and writes inside the lock, so two overlapping post_tool_use calls
671
+ // cannot both advance past the same counter value nor both emit at the
672
+ // threshold.
673
+ return _withToolWriteLock(consumer_root, session_id, EXIT_ALLOW, () => { const previous = _loadState(path.join(consumer_root, statePathFor(session_id)));
674
+ const language = previous.language as Verdict | undefined;
675
+ const source: PinSource = previous.source === "system-locale" ? "system-locale" : "prompt";
676
+ // Clear FIRST: if the write below throws, the marker must not survive to
677
+ // re-fire on every subsequent tool call — that is the shape § 6.2 refuses.
678
+ _clearPinLost(consumer_root, session_id);
679
+ if (language !== "de" && language !== "en") {
680
+ return EXIT_ALLOW;
681
+ }
682
+ // A foreign session's pin is not this session's to restore — same reading as
683
+ // `nextState`, for the same shared-state reason.
684
+ if (!_ownsPin(previous, session_id)) {
685
+ return EXIT_ALLOW;
686
+ }
687
+ // The pin is about to be fresh in context again, so the distance counter
688
+ // starts over. Without this the two re-emit triggers would drift apart and
689
+ // a post-compaction session could re-state the pin twice in a row.
690
+ //
691
+ // BLOCKER 4, council round 2: this used to ignore the result and emit anyway.
692
+ // A reset that does not land leaves the counter at 149, so the next successful
693
+ // tool-hook write reaches 150 and emits a SECOND reminder — which contradicts
694
+ // the "compaction wins" invariant this file documents. Same fail-closed policy
695
+ // as the distance path: no durable reset, no emit.
696
+ if (!_resetDistance(consumer_root, previous, session_id, write_json)) {
697
+ return EXIT_ALLOW;
698
+ }
699
+ process.stdout.write(
700
+ `${JSON.stringify({
701
+ decision: "warn",
702
+ reason: noticeText(language, source),
703
+ additional_context: pinText(language, source),
704
+ })}\n`,
705
+ );
706
+ return EXIT_WARN;
707
+ });
708
+ }
709
+
710
+ /**
711
+ * Remove the pre-split single file once this version owns the tree.
712
+ *
713
+ * Not a migration: a new session has no pin to inherit anyway, so there is
714
+ * nothing in the old file worth carrying across. Leaving it would leave dead
715
+ * state that looks live to anyone reading the directory.
716
+ */
717
+ export function _pruneLegacyState(consumer_root: string): void {
718
+ prune_legacy_state_file(path.join(consumer_root, STATE_FILE));
719
+ }
720
+
721
+ /**
722
+ * Drop session states untouched for longer than the retention window.
723
+ *
724
+ * One file per session is unbounded growth otherwise. Called on the prompt
725
+ * path only — once per turn, never per tool call.
726
+ */
727
+ export function _pruneStaleSessions(
728
+ consumer_root: string,
729
+ now_ms: number,
730
+ /**
731
+ * How a file's age is read. Injectable so the claim-then-revalidate race in
732
+ * the shared pruner is reachable from a test — the branch only runs when the
733
+ * candidate check and the post-claim check DISAGREE, which no cutoff-only
734
+ * test can stage.
735
+ */
736
+ mtimeOf: (target: string) => number = (target) => fs.statSync(target).mtimeMs,
737
+ ): number {
738
+ return prune_stale_session_states(
739
+ path.join(consumer_root, STATE_DIR),
740
+ now_ms,
741
+ STATE_RETENTION_DAYS,
742
+ mtimeOf,
743
+ );
744
+ }
745
+
746
+ /**
747
+ * Persist a counter value. Returns whether it landed; never raises into the turn.
748
+ *
749
+ * The boolean is load-bearing, and the comment it replaces was a false
750
+ * guarantee (council review 2026-08-20, both seats): "a failed write costs at
751
+ * most one late re-emit" is true for a failed INCREMENT and false for a failed
752
+ * RESET. A reset that does not land leaves the counter at the threshold, so
753
+ * every subsequent tool call re-fires — unbounded repeated reminders, which is
754
+ * exactly the shape § 6.2 refuses. The caller must therefore know.
755
+ */
756
+ /**
757
+ * How long a lock file may sit before it is treated as abandoned.
758
+ *
759
+ * A whole hook run is milliseconds, so anything this old belongs to a process
760
+ * that died holding it. Deliberately NOT retried after a break: a retry loop
761
+ * re-opens exactly the window the lock exists to close.
762
+ */
763
+ export const LOCK_STALE_MS = 30_000;
764
+
765
+ /**
766
+ * Serialise the TOOL-side state update for one session.
767
+ *
768
+ * OWN FINDING, not a council one — stated that way on purpose. Round 3 asked
769
+ * both seats about this file and NEITHER named it: the artefact covers the
770
+ * pruner TOCTOU, the crash-stranded tombstones, `_ownsPin`, the foreign-owner
771
+ * test gap and the `chmod` portability, and nothing else. It was first written
772
+ * here as "council round 3 (OpenAI seat)", a peer checked the artefact and
773
+ * refuted that, and the attribution is corrected rather than quietly dropped —
774
+ * a comment claiming a reviewer found something is exactly the kind of false
775
+ * line that let a real blocker survive round 2 of this same file.
776
+ *
777
+ * The defect is real regardless of who found it, and the counter-probe is the
778
+ * evidence: the counter path was a read-decide-write over a snapshot taken at
779
+ * the start of the hook run, with nothing serialising two of them. Two overlapping `post_tool_use` calls both
780
+ * read the same counter and both wrote the same increment (one tool call lost),
781
+ * and at the threshold both read 149, both reset to zero and both emitted —
782
+ * which breaks the one-re-emit-per-block invariant this mechanism is bounded to.
783
+ *
784
+ * The protocol is deliberately ASYMMETRIC, and that is the whole reason it can
785
+ * be this small:
786
+ *
787
+ * - the PROMPT write is authoritative. A new prompt pin is meant to replace
788
+ * the state, counter included, so it takes no lock and always wins.
789
+ * - the TOOL write is defensive. It never replaces a pin, only advances a
790
+ * counter, so it serialises against other tool writes here and re-reads
791
+ * the pin inside `_writeDistance` before touching anything.
792
+ *
793
+ * A lock that cannot be taken means a peer is mid-update: return the caller's
794
+ * silent value. A lost reminder is recoverable, a double reminder is the shape
795
+ * § 6.2 refuses.
796
+ */
797
+ function _withToolWriteLock<T>(
798
+ consumer_root: string,
799
+ session_id: string,
800
+ when_busy: T,
801
+ body: () => T,
802
+ now_ms: number = Date.now(),
803
+ ): T {
804
+ const lock = `${path.join(consumer_root, statePathFor(session_id))}.lock`;
805
+ let fd: number;
806
+ const acquire = (): number | null => {
807
+ try {
808
+ fs.mkdirSync(path.dirname(lock), { recursive: true });
809
+ return fs.openSync(lock, "wx");
810
+ } catch (err) {
811
+ if ((err as NodeJS.ErrnoException).code !== "EEXIST") return null;
812
+ let age: number;
813
+ try {
814
+ age = now_ms - fs.statSync(lock).mtimeMs;
815
+ } catch {
816
+ return null; // vanished under us — a peer is actively working
817
+ }
818
+ if (age < LOCK_STALE_MS) return null;
819
+ try {
820
+ fs.rmSync(lock, { force: true });
821
+ return fs.openSync(lock, "wx");
822
+ } catch {
823
+ return null; // a peer broke it first and now holds it
824
+ }
825
+ }
826
+ };
827
+ const held = acquire();
828
+ if (held === null) return when_busy;
829
+ fd = held;
830
+ try {
831
+ return body();
832
+ } finally {
833
+ try {
834
+ fs.closeSync(fd);
835
+ } catch {
836
+ /* already closed */
837
+ }
838
+ try {
839
+ fs.rmSync(lock, { force: true });
840
+ } catch {
841
+ /* a peer broke a stale lock and owns the path now */
842
+ }
843
+ }
844
+ }
845
+
846
+ export function _writeDistance(
847
+ consumer_root: string,
848
+ /**
849
+ * The pin the CALLER decided against. Used only to detect that the decision
850
+ * has since been overtaken — never as the object that gets written. Passing a
851
+ * snapshot here and writing it back is the defect itself.
852
+ */
853
+ expected: Partial<PinState>,
854
+ session_id: string,
855
+ value: number,
856
+ /**
857
+ * How the counter is persisted. Injectable for the same reason the pruner
858
+ * injects its age reader, and council round 3 asked for exactly this parity:
859
+ * the fail-closed policy is only observable when the write FAILS, and the
860
+ * `chmod 0o500` that used to stage that is silently ineffective for an
861
+ * elevated user — a root CI ran those tests green against any
862
+ * implementation. A thrown error from here reaches the same catch a real
863
+ * ENOSPC or EACCES would.
864
+ */
865
+ write_json: StateWriter = atomic_write_json,
866
+ ): boolean {
867
+ if (is_replay_mode()) return false;
868
+ // No stable identity → nothing may be persisted (BLOCKER 2). Reported as a
869
+ // failed write so every fail-closed caller stays silent rather than emitting
870
+ // against a counter that cannot be reset.
871
+ if (!hasStableSessionId(session_id)) return false;
872
+ const target = path.join(consumer_root, statePathFor(session_id));
873
+ // Re-read HERE, and build the write from the fresh object — never from the
874
+ // caller's snapshot.
875
+ //
876
+ // This used to spread `previous`, loaded at the top of the hook run. A substantive prompt landing in between
877
+ // writes a NEW pin, and the tool hook then replaced that whole object with
878
+ // the stale one — language, source, timestamp and all. The wrong language
879
+ // came back with no hash collision and no lost update involved, which is the
880
+ // very defect this file exists to close. `atomic_write_json` prevents a torn
881
+ // file; it cannot prevent a stale whole-object replacement.
882
+ const current = _loadState(target);
883
+ // Nothing to advance: no pin, or a pin this session does not own.
884
+ if (!_ownsPin(current, session_id)) return false;
885
+ // The decision was made against a pin that is no longer the live one, so the
886
+ // counter it computed does not describe this state. Report a failed write so
887
+ // every fail-closed caller stays silent; the next tool call re-decides
888
+ // against the new pin. `detected_at` alone would catch a replacement, and
889
+ // language/source are compared as well so a same-millisecond re-pin in a
890
+ // different language cannot slip through.
891
+ if (
892
+ current.detected_at !== expected.detected_at ||
893
+ current.language !== expected.language ||
894
+ current.source !== expected.source
895
+ ) {
896
+ return false;
897
+ }
898
+ try {
899
+ write_json(target, {
900
+ ...current,
901
+ session_id: typeof current.session_id === "string" && current.session_id !== ""
902
+ ? current.session_id
903
+ : session_id,
904
+ tool_calls_since_pin: value,
905
+ } as PinState);
906
+ return true;
907
+ } catch {
908
+ return false;
909
+ }
910
+ }
911
+
912
+ export type StateWriter = (target: string, state: PinState) => void;
913
+
914
+ function _resetDistance(
915
+ consumer_root: string,
916
+ expected: Partial<PinState>,
917
+ session_id: string,
918
+ write_json: StateWriter = atomic_write_json,
919
+ ): boolean {
920
+ return _writeDistance(consumer_root, expected, session_id, 0, write_json);
921
+ }
922
+
923
+ /**
924
+ * The distance trigger — the second of the two re-emit paths.
925
+ *
926
+ * WHY THIS IS NOT "the same failed mechanism running more often" (§ 6.2, and
927
+ * the 2026-07-06 reminder-injection null this repo settled at Δ=0): that pilot
928
+ * could not produce a red baseline — kernel-only complied 12/12 — and its own
929
+ * revisit clause reopens the question the moment one is found: *"a real red
930
+ * baseline — e.g. genuine >3K-token distance in a live multi-turn session, or
931
+ * telemetry showing tier-2 obligations missed in production"*. Production
932
+ * transcripts now supply exactly that: 11 English replies to a German user,
933
+ * every one of them 179+ tool calls past the pin, none below it. So this is a
934
+ * measured-gap fix in the regime the null explicitly excluded, not a re-run of
935
+ * the refused shape — which was a re-pin on EVERY tool call, unbounded. This
936
+ * one fires once per `REEMIT_AFTER_TOOL_CALLS` — once per ~200-call block. It
937
+ * does reach some compliant traffic (compliant p99 = 184 > 150); the claim is
938
+ * bounded frequency against a measured red baseline, never zero false fires.
939
+ */
940
+ export function _reEmitAfterDistance(
941
+ consumer_root: string,
942
+ session_id: string,
943
+ write_json: StateWriter = atomic_write_json,
944
+ ): number {
945
+ if (is_replay_mode()) {
946
+ return EXIT_ALLOW;
947
+ }
948
+ // Stateless without an id: no counter to advance, so no distance to measure
949
+ // (BLOCKER 2). The prompt path still pins from a marked prompt.
950
+ if (!hasStableSessionId(session_id)) {
489
951
  return EXIT_ALLOW;
490
952
  }
491
- process.stdout.write(
492
- `${JSON.stringify({
493
- decision: "warn",
494
- reason: noticeText(language, source),
495
- additional_context: pinText(language, source),
496
- })}\n`,
497
- );
498
- return EXIT_WARN;
953
+
954
+ // Serialised against any peer tool write for this session, and silent when
955
+ // one holds the lock. Everything below reads,
956
+ // decides and writes inside the lock, so two overlapping post_tool_use calls
957
+ // cannot both advance past the same counter value nor both emit at the
958
+ // threshold.
959
+ return _withToolWriteLock(consumer_root, session_id, EXIT_ALLOW, () => { const previous = _loadState(path.join(consumer_root, statePathFor(session_id)));
960
+ const language = previous.language as Verdict | undefined;
961
+ if (language !== "de" && language !== "en") {
962
+ return EXIT_ALLOW;
963
+ }
964
+ if (!_ownsPin(previous, session_id)) {
965
+ return EXIT_ALLOW;
966
+ }
967
+ const seen =
968
+ typeof previous.tool_calls_since_pin === "number" && previous.tool_calls_since_pin >= 0
969
+ ? previous.tool_calls_since_pin
970
+ : 0;
971
+ const advanced = seen + 1;
972
+ const due = advanced >= REEMIT_AFTER_TOOL_CALLS;
973
+ // Write BEFORE emitting, and reset on the same call that emits: if the
974
+ // stdout write throws, the counter must not stay at the threshold and fire
975
+ // on every subsequent tool call.
976
+ const written = _writeDistance(
977
+ consumer_root,
978
+ previous,
979
+ session_id,
980
+ due ? 0 : advanced,
981
+ write_json,
982
+ );
983
+ if (!due) {
984
+ return EXIT_ALLOW;
985
+ }
986
+ // FAIL-CLOSED, stated as a policy rather than left to the catch: if the reset
987
+ // did not land, staying silent loses at most one reminder, while emitting
988
+ // anyway re-fires on every later tool call because the counter is still at
989
+ // the threshold. A lost reminder is recoverable; an unbounded loop is the
990
+ // failure this whole mechanism is bounded to avoid.
991
+ if (!written) {
992
+ return EXIT_ALLOW;
993
+ }
994
+ const source: PinSource = previous.source === "system-locale" ? "system-locale" : "prompt";
995
+ process.stdout.write(
996
+ `${JSON.stringify({
997
+ decision: "warn",
998
+ reason: noticeText(language, source),
999
+ additional_context: pinText(language, source),
1000
+ })}\n`,
1001
+ );
1002
+ return EXIT_WARN;
1003
+ });
1004
+ }
1005
+
1006
+ /**
1007
+ * `post_tool_use` carries both re-emit triggers. Compaction wins when both are
1008
+ * live: it already re-states the pin and resets the distance counter, so
1009
+ * running the distance path after it would emit twice for one event.
1010
+ */
1011
+ export function _onToolUse(
1012
+ consumer_root: string,
1013
+ session_id: string,
1014
+ write_json: StateWriter = atomic_write_json,
1015
+ ): number {
1016
+ if (_pinLost(consumer_root, session_id)) {
1017
+ return _reEmitAfterCompaction(consumer_root, session_id, write_json);
1018
+ }
1019
+ return _reEmitAfterDistance(consumer_root, session_id, write_json);
499
1020
  }
500
1021
 
501
1022
  export function run(
502
1023
  stdin_text: string,
503
- options: { consumer_root: string; env?: NodeJS.ProcessEnv },
1024
+ options: { consumer_root: string; env?: NodeJS.ProcessEnv; write_json?: StateWriter },
504
1025
  ): number {
505
1026
  let envelope: JsonObject = {};
506
1027
  if (stdin_text.trim()) {
@@ -536,7 +1057,7 @@ export function run(
536
1057
  return EXIT_ALLOW;
537
1058
  }
538
1059
  if (event === "post_tool_use") {
539
- return _reEmitAfterCompaction(options.consumer_root, _sessionId(envelope));
1060
+ return _onToolUse(options.consumer_root, _sessionId(envelope), options.write_json);
540
1061
  }
541
1062
 
542
1063
  const prompt = _extractPrompt(payload);
@@ -551,8 +1072,13 @@ export function run(
551
1072
  }
552
1073
 
553
1074
  const session_id = typeof envelope["session_id"] === "string" ? envelope["session_id"] : "";
554
- const target = path.join(options.consumer_root, STATE_FILE);
555
- const previous = _loadState(target);
1075
+ const stateful = hasStableSessionId(session_id);
1076
+ // BLOCKER 2: with no stable id there is no file to read or write. The pin for
1077
+ // THIS turn still comes from this turn's prompt — that needs no state — but
1078
+ // terse-continuation inheritance and the distance counter are both off, since
1079
+ // both would have to share one bucket with every other id-less invocation.
1080
+ const target = stateful ? path.join(options.consumer_root, statePathFor(session_id)) : "";
1081
+ const previous = stateful ? _loadState(target) : {};
556
1082
  const classification = classify(prompt);
557
1083
  const next = nextState(
558
1084
  previous,
@@ -566,16 +1092,26 @@ export function run(
566
1092
  systemLocaleVerdict(options.env ?? process.env),
567
1093
  );
568
1094
 
569
- const effective = next?.language ?? (previous.language as Verdict | undefined);
1095
+ // A foreign session's pin is never inherited — see `nextState`. Without this,
1096
+ // the `null` "keep" return and a cross-session read are indistinguishable
1097
+ // here, which is the path by which an English pin reached a German turn.
1098
+ const inherited = _ownsPin(previous, session_id)
1099
+ ? (previous.language as Verdict | undefined)
1100
+ : undefined;
1101
+ const effective = next?.language ?? inherited;
570
1102
  // A state file written before `source` existed carries only what the old
571
1103
  // version could produce — a prompt reading. Absent is therefore `prompt`,
572
1104
  // never "unknown": treating it as unknown would reword the audited notice
573
1105
  // for every pre-existing session.
574
1106
  const effectiveSource: PinSource =
575
1107
  next?.source ?? (previous.source === "system-locale" ? "system-locale" : "prompt");
576
- if (next) {
1108
+ if (next && stateful) {
577
1109
  try {
578
1110
  atomic_write_json(target, next);
1111
+ // Housekeeping runs on the prompt path only — once per turn, not once
1112
+ // per tool call — and never before the write it is cleaning up after.
1113
+ _pruneLegacyState(options.consumer_root);
1114
+ _pruneStaleSessions(options.consumer_root, Date.now());
579
1115
  } catch {
580
1116
  // Observability only — a failed state write never blocks the turn.
581
1117
  }