@event4u/agent-config 14.5.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 (203) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/CHANGELOG.md +384 -3
  3. package/README.md +1 -1
  4. package/dist/agent-src/commands/analyze/decision.md +7 -0
  5. package/dist/agent-src/commands/analyze/inbox.md +264 -10
  6. package/dist/agent-src/commands/analyze.md +1 -1
  7. package/dist/agent-src/commands/optimize/project.md +10 -0
  8. package/dist/agent-src/commands/roadmap/process-full.md +132 -4
  9. package/dist/agent-src/contexts/execution/roadmap-execution-contract.md +31 -0
  10. package/dist/agent-src/contexts/execution/roadmap-process-loop.md +161 -1
  11. package/dist/agent-src/rules/context-hygiene.md +2 -0
  12. package/dist/agent-src/rules/decision-revisit-gate.md +111 -7
  13. package/dist/agent-src/rules/fix-what-you-see.md +108 -0
  14. package/dist/agent-src/rules/no-roadmap-references.md +0 -5
  15. package/dist/agent-src/rules/recurring-criticism.md +110 -0
  16. package/dist/agent-src/rules/roadmap-progress-sync.md +1 -1
  17. package/dist/agent-src/rules/rule-type-governance.md +0 -2
  18. package/dist/agent-src/rules/self-repair-loop.md +17 -0
  19. package/dist/agent-src/rules/skill-quality.md +0 -2
  20. package/dist/agent-src/rules/source-confidentiality.md +37 -7
  21. package/dist/agent-src/scripts/archival_sweep.ts +117 -0
  22. package/dist/agent-src/scripts/archive_completed_roadmaps.ts +19 -3
  23. package/dist/agent-src/scripts/gate_budget.ts +282 -0
  24. package/dist/agent-src/scripts/gate_execute.ts +72 -31
  25. package/dist/agent-src/scripts/roadmap_gates.ts +74 -3
  26. package/dist/agent-src/scripts/update_roadmap_progress.ts +251 -7
  27. package/dist/agent-src/skills/adr-create/SKILL.md +25 -3
  28. package/dist/agent-src/skills/ai-council/SKILL.md +48 -807
  29. package/dist/agent-src/skills/ai-council/references/advanced-modes.md +320 -0
  30. package/dist/agent-src/skills/ai-council/references/cost-and-redaction.md +99 -0
  31. package/dist/agent-src/skills/ai-council/references/execution-modes.md +162 -0
  32. package/dist/agent-src/skills/ai-council/references/output-and-synthesis.md +125 -0
  33. package/dist/agent-src/skills/ai-council/references/procedure.md +143 -0
  34. package/dist/agent-src/skills/decision-review/SKILL.md +18 -0
  35. package/dist/agent-src/skills/quality-tools/SKILL.md +34 -326
  36. package/dist/agent-src/skills/quality-tools/references/js-ts-tools.md +113 -0
  37. package/dist/agent-src/skills/quality-tools/references/php-tools.md +222 -0
  38. package/dist/agent-src/skills/roadmap-management/SKILL.md +15 -409
  39. package/dist/agent-src/skills/roadmap-management/references/archival.md +226 -0
  40. package/dist/agent-src/skills/roadmap-management/references/authoring.md +107 -0
  41. package/dist/agent-src/skills/roadmap-management/references/dashboard.md +47 -0
  42. package/dist/agent-src/skills/roadmap-management/references/lifecycle.md +58 -0
  43. package/dist/agent-src/skills/skill-writing/SKILL.md +34 -570
  44. package/dist/agent-src/skills/skill-writing/references/procedure.md +275 -0
  45. package/dist/agent-src/skills/skill-writing/references/read-only-scripts.md +18 -0
  46. package/dist/agent-src/skills/skill-writing/references/section-patterns.md +298 -0
  47. package/dist/agent-src/templates/agents/agent-project-settings.example.yml +1 -1
  48. package/dist/agent-src/templates/roadmaps.md +45 -2
  49. package/dist/agent-src/templates/scripts/telemetry/flush_sender.mjs +163 -0
  50. package/dist/agent-src/templates/scripts/telemetry/remote.ts +160 -7
  51. package/dist/agent-src/templates/scripts/telemetry/transport.ts +169 -0
  52. package/dist/cli/registry.js +1 -1
  53. package/dist/cli/registry.js.map +1 -1
  54. package/dist/cli-delegate/chunk-2GNWGMAL.js +172 -0
  55. package/dist/cli-delegate/{chunk-RLN73PR5.js → chunk-4VR4ORQ6.js} +10 -7
  56. package/dist/cli-delegate/chunk-6EOCNQAM.js +402 -0
  57. package/dist/cli-delegate/{chunk-XURJ4AQC.js → chunk-74E3TKWV.js} +381 -280
  58. package/dist/cli-delegate/{chunk-3UCJWMMP.js → chunk-7F3VYSM2.js} +4 -4
  59. package/dist/cli-delegate/{chunk-BKO2AZ7Z.js → chunk-B2ERU2VA.js} +1 -1
  60. package/dist/cli-delegate/{chunk-NXFWFAYV.js → chunk-G23AT6GD.js} +1 -0
  61. package/dist/cli-delegate/{chunk-63WSP6YZ.js → chunk-GJ5BVHXM.js} +72 -165
  62. package/dist/cli-delegate/{chunk-ZPSXJTLI.js → chunk-JZ4NR2TW.js} +353 -1
  63. package/dist/cli-delegate/chunk-MWRD5YOH.js +100 -0
  64. package/dist/cli-delegate/{chunk-GRRPEPRN.js → chunk-T7L2QK3K.js} +24 -5
  65. package/dist/cli-delegate/{chunk-EVTY7ISR.js → chunk-YM4GZDJG.js} +9 -1
  66. package/dist/cli-delegate/cmd_conformance.js +459 -709
  67. package/dist/cli-delegate/cmd_doctor.js +6 -6
  68. package/dist/cli-delegate/cmd_explain.js +1 -1
  69. package/dist/cli-delegate/cmd_export.js +7 -6
  70. package/dist/cli-delegate/cmd_handoff.js +6 -6
  71. package/dist/cli-delegate/cmd_packs_active.js +1 -1
  72. package/dist/cli-delegate/cmd_preflight.js +2 -2
  73. package/dist/cli-delegate/cmd_prune.js +7 -6
  74. package/dist/cli-delegate/cmd_session_recycle.js +840 -30
  75. package/dist/cli-delegate/cmd_settings_migrate.js +7 -6
  76. package/dist/cli-delegate/cmd_settings_set.js +1 -1
  77. package/dist/cli-delegate/cmd_sync.js +7 -6
  78. package/dist/cli-delegate/cmd_uninstall.js +9 -7
  79. package/dist/cli-delegate/cmd_update.js +1 -1
  80. package/dist/cli-delegate/cmd_upgrade.js +1 -1
  81. package/dist/cli-delegate/cmd_validate.js +7 -6
  82. package/dist/discovery/deprecation-report.md +1 -1
  83. package/dist/discovery/discovery-manifest.json +72 -29
  84. package/dist/discovery/discovery-manifest.json.sha256 +1 -1
  85. package/dist/discovery/discovery-manifest.summary.md +4 -4
  86. package/dist/discovery/dormancy-report.md +1 -1
  87. package/dist/discovery/orphan-report.md +1 -1
  88. package/dist/discovery/packs.json +10 -8
  89. package/dist/discovery/trust-report.md +3 -3
  90. package/dist/discovery/workspaces.json +8 -6
  91. package/dist/hooks/dispatch.js +3127 -2022
  92. package/dist/install/claudePathsPlan.js +187 -0
  93. package/dist/install/claudePathsPlan.js.map +1 -0
  94. package/dist/install/hostLayerFingerprint.js +136 -0
  95. package/dist/install/hostLayerFingerprint.js.map +1 -0
  96. package/dist/install/install.mjs +553 -425
  97. package/dist/install/partitionEligibility.js +276 -0
  98. package/dist/install/partitionEligibility.js.map +1 -0
  99. package/dist/mcp/registry-manifest.json +3 -3
  100. package/dist/mcp/server.json +2 -2
  101. package/dist/mcp/server.mjs +1744 -1191
  102. package/dist/router.json +1 -1
  103. package/dist/scripts/_lib/agent_src.js +41 -0
  104. package/dist/scripts/_lib/agent_src.js.map +1 -1
  105. package/dist/scripts/_lib/install_layout.js +83 -0
  106. package/dist/scripts/_lib/install_layout.js.map +1 -0
  107. package/dist/scripts/_lib/installed_lock.js +444 -0
  108. package/dist/scripts/_lib/installed_lock.js.map +1 -0
  109. package/dist/scripts/condense.js +13 -164
  110. package/dist/scripts/condense.js.map +1 -1
  111. package/dist/server/schemas/settings.js +4 -0
  112. package/dist/server/schemas/settings.js.map +1 -1
  113. package/docs/contracts/settings-classes.md +12 -6
  114. package/docs/guidelines/agent-infra/agent-interaction-and-decision-quality.md +8 -3
  115. package/docs/guidelines/agent-infra/context-hygiene-mechanics.md +26 -0
  116. package/docs/guidelines/agent-infra/engineering-memory-data-format.md +49 -0
  117. package/docs/guidelines/agent-infra/memory-access.md +17 -0
  118. package/docs/guidelines/agent-infra/recurring-criticism-mechanics.md +96 -0
  119. package/package.json +1 -1
  120. package/src/agent-src/scripts/archival_sweep.ts +117 -0
  121. package/src/agent-src/scripts/archive_completed_roadmaps.ts +19 -3
  122. package/src/agent-src/scripts/gate_budget.ts +282 -0
  123. package/src/agent-src/scripts/gate_execute.ts +72 -31
  124. package/src/agent-src/scripts/roadmap_gates.ts +74 -3
  125. package/src/agent-src/scripts/update_roadmap_progress.ts +251 -7
  126. package/src/agent-src/templates/scripts/telemetry/flush_sender.mjs +163 -0
  127. package/src/agent-src/templates/scripts/telemetry/remote.ts +160 -7
  128. package/src/agent-src/templates/scripts/telemetry/transport.ts +169 -0
  129. package/src/config/agent-settings.template.yml +27 -2
  130. package/src/config/ci-local-parity.yml +18 -0
  131. package/src/config/estate-count-budget.json +367 -3
  132. package/src/config/gate-violation-baselines.json +41 -41
  133. package/src/config/pack-size-budget.json +3 -2
  134. package/src/config/rule-activation-census.json +14 -6
  135. package/src/config/rule-stub-ceilings.json +19 -6
  136. package/src/install/claudePathsPlan.ts +196 -0
  137. package/src/install/hostLayerFingerprint.ts +145 -0
  138. package/src/install/partitionEligibility.ts +325 -0
  139. package/src/scripts/_cli/cmd_session_recycle.ts +9 -1
  140. package/src/scripts/_dispatch.bash +6 -1
  141. package/src/scripts/_lib/ac_heading.ts +30 -0
  142. package/src/scripts/_lib/agent_src.ts +42 -0
  143. package/src/scripts/_lib/bench_ab_pinned_repo.ts +162 -0
  144. package/src/scripts/_lib/bench_ab_workspace.ts +10 -1
  145. package/src/scripts/_lib/envelope_grounding.ts +38 -8
  146. package/src/scripts/_lib/installed_lock.ts +50 -5
  147. package/src/scripts/_lib/layer_overlap_notice.ts +85 -0
  148. package/src/scripts/_lib/md_section.ts +129 -0
  149. package/src/scripts/_lib/scope_guard.sh +86 -4
  150. package/src/scripts/_lib/self_repair.ts +19 -0
  151. package/src/scripts/_lib/self_repair_class_b.ts +171 -0
  152. package/src/scripts/_lib/subagent_capsule.ts +81 -1
  153. package/src/scripts/adr/regenerate_index.ts +13 -2
  154. package/src/scripts/adr_cite_check.ts +424 -0
  155. package/src/scripts/ai_council/cli_help.ts +55 -0
  156. package/src/scripts/before_complete_hook.ts +153 -28
  157. package/src/scripts/bench_ab_v2_complexity.ts +27 -7
  158. package/src/scripts/check_adr_frontmatter.ts +187 -1
  159. package/src/scripts/check_references.ts +60 -0
  160. package/src/scripts/check_release_published.ts +32 -11
  161. package/src/scripts/check_single_delivery.ts +517 -0
  162. package/src/scripts/ci_settle.ts +170 -0
  163. package/src/scripts/condense.ts +20 -170
  164. package/src/scripts/council_cli.ts +3 -3
  165. package/src/scripts/dispatch_r2_reviewer.ts +6 -14
  166. package/src/scripts/hook_manifest.json +1 -1
  167. package/src/scripts/hook_manifest.yaml +77 -14
  168. package/src/scripts/hooks/block_no_verify.ts +61 -10
  169. package/src/scripts/hooks/block_unauthorized_git.ts +1 -1
  170. package/src/scripts/hooks/concern_registry.ts +4 -0
  171. package/src/scripts/hooks/rtk_wrap_hook.ts +8 -3
  172. package/src/scripts/hooks/run_continuation_hook.ts +887 -68
  173. package/src/scripts/hooks/ship_diff_volume_hook.ts +81 -15
  174. package/src/scripts/hooks/source_first_gate_hook.ts +464 -0
  175. package/src/scripts/hooks/state_io.ts +782 -20
  176. package/src/scripts/hooks/subagent_ledger_hook.ts +44 -15
  177. package/src/scripts/hooks/telemetry_flush_hook.ts +139 -0
  178. package/src/scripts/hooks/telemetry_self_repair.ts +128 -0
  179. package/src/scripts/hooks/telemetry_usage_hook.ts +22 -4
  180. package/src/scripts/hooks/turn_end_gate_hook.ts +81 -15
  181. package/src/scripts/install.sh +12 -2
  182. package/src/scripts/install.ts +16 -11
  183. package/src/scripts/language_mirror_hook.ts +561 -25
  184. package/src/scripts/lint_framework_leakage_allowlist.json +2 -2
  185. package/src/scripts/lint_plan_risk_register.ts +4 -3
  186. package/src/scripts/lint_skill_router_head.ts +4 -4
  187. package/src/scripts/mcp_server/tools.ts +2 -2
  188. package/src/scripts/memory_consolidation.ts +189 -0
  189. package/src/scripts/memory_eviction.ts +384 -0
  190. package/src/scripts/new_roadmap.ts +136 -0
  191. package/src/scripts/pack_mcp_content.ts +20 -5
  192. package/src/scripts/release.ts +37 -38
  193. package/src/scripts/report_layer_overlap.ts +78 -0
  194. package/src/scripts/report_memory_pointers.ts +622 -0
  195. package/src/scripts/self_repair_hook.ts +37 -5
  196. package/src/scripts/session_register_hook.ts +115 -15
  197. package/src/scripts/skill_usage_collect.ts +11 -1
  198. package/src/scripts/skill_usage_report.ts +278 -8
  199. package/src/scripts/sync_pr_branch.ts +7 -0
  200. package/src/server/schemas/settings.ts +8 -0
  201. package/dist/cli-delegate/chunk-EJITJBJX.js +0 -172
  202. package/dist/cli-delegate/chunk-PZWRPRWP.js +0 -38
  203. /package/dist/cli-delegate/{chunk-RYNADUFG.js → chunk-LWTYTX6F.js} +0 -0
@@ -40,22 +40,64 @@
40
40
  * TERMINATION LADDER (Phase 1.1) — every rung a named event in
41
41
  * `agents/runtime/state/run-continuation.jsonl`:
42
42
  * contract absent → no-op (no event; the common case is silent)
43
+ * roadmap unreadable from the
44
+ * authoritative tree → allow (event: halt-roadmap-absent, ONCE PER
45
+ * ABSENCE EPISODE with a readable state and ONCE PER
46
+ * RUN without one — the two guards have different
47
+ * carriers: `absent_fires` resets on a successful
48
+ * read, the ledger guard cannot forget. So a second
49
+ * episode on an unparseable state is SILENT,
50
+ * only for a run this concern had already engaged
51
+ * on THIS roadmap — the keyed state is the
52
+ * discriminator. State absent stays silent,
53
+ * because nothing was ever driven. The budget is
54
+ * reclaimed only after `ABSENT_CONFIRM_FIRES`
55
+ * consecutive absent fires: one fire cannot tell
56
+ * an archival from a non-atomic rewrite, two can.
57
+ * Deliberately NOT in `HALT_ACTIONS` — it clears
58
+ * the budget rather than stamping `halted`, and
59
+ * the two are exclusive)
60
+ * transcript over the shared
61
+ * read cap → allow (event: inert-transcript-over-cap, ONCE
62
+ * per run. The concern cannot compute a turn
63
+ * ordinal past the cap, so it goes inert — and an
64
+ * inert mechanism that says nothing is
65
+ * indistinguishable from a healthy idle run,
66
+ * which is the failure this file exists over)
43
67
  * quality-gate refusal → defer (event: deferred-quality-gate)
44
68
  * duplicate stop fire → repeat the BLOCK, no count (event: none —
45
69
  * an allow here would END the reply the block
46
- * one event earlier exists to continue)
47
- * scope complete (0 open) → allow (event: complete; state cleared)
70
+ * one event earlier exists to continue). A fire
71
+ * whose roadmap SOURCE changed is never a
72
+ * duplicate, whatever the counts say.
73
+ * scope complete (0 open,
74
+ * none blocked) → allow (event: complete; state cleared)
75
+ * remaining work externally
76
+ * impossible (0 open, ≥1
77
+ * blocked) → allow (event: blocked, ONCE per run, guarded by
78
+ * the LEDGER: the state is cleared like `complete`,
79
+ * so no field there outlives the event. A terminal
80
+ * outcome — not a sixth halt, never in
81
+ * `HALT_ACTIONS`, never stamped into `halted`.
82
+ * ADR-237 narrows what may legally wear a
83
+ * `blocked-by:` marker; semantics unchanged.)
48
84
  * iterations ≥ MAX (25) → allow (event: halt-max-iterations)
49
85
  * wall clock ≥ cap (4 h) → allow (event: halt-wall-clock)
50
86
  * stall (3 engagements, no
51
87
  * checkbox delta) → allow (event: halt-stall)
52
88
  * otherwise → BLOCK + continue (event: engage)
53
89
  *
54
- * Every event this concern writes carries `workspace_root`, `git_dir`,
55
- * `git_common_dir` and `claim_path` — the two-tree provenance, per
56
- * `provenance()` below. Without them the ledger records that an engagement
57
- * happened but not that it crossed the tree boundary the fix was about, and
58
- * crossing it is the whole claim.
90
+ * Every event this concern writes carries `workspace_root`, `session_root`,
91
+ * `session_cwd`, `git_dir`, `git_common_dir` and `claim_path` — the two-tree
92
+ * provenance, per `provenance()` below — plus `roadmap_path`, the file the
93
+ * open-step count on that same line was read from (round 4 finding 4). Seven
94
+ * fields. The count has been caught wrong in three separate rounds; if you add
95
+ * one, this line, `provenance()`s heading and the roadmap enumeration all move. Without them the ledger records that an
96
+ * engagement happened but not that it crossed the tree boundary the fix was
97
+ * about, and crossing it is the whole claim. This list is the file's canonical
98
+ * statement of the contract: R2 round 2 finding 4 caught it enumerating four
99
+ * fields while the function emitted five, which would have let a later edit drop
100
+ * one without contradicting any documented promise.
59
101
  *
60
102
  * Stall counts CONSECUTIVE ENGAGEMENTS whose open-step count did not move.
61
103
  * The upstream stalls on 5-of-5 completed-count readings; AC's checkbox
@@ -94,7 +136,7 @@ import { unwrap, type JsonObject, type JsonValue } from './envelope.js';
94
136
  import { readHookStdin } from './hook_stdin.js';
95
137
  import { is_replay_mode } from './state_io.js';
96
138
  import { readTranscriptTail } from './turn_end_gate_hook.js';
97
- import { resolve_claim } from '../session_register_hook.js';
139
+ import { resolve_claim, session_checkout } from '../session_register_hook.js';
98
140
  import { git_common_dir, git_dir } from '../_lib/git_common_dir.js';
99
141
  import {
100
142
  deriveSessionKey,
@@ -129,6 +171,17 @@ import { TRANSCRIPT_READ_MAX_BYTES } from './turn_end_gate_hook.js';
129
171
  /** Re-exported so a test can assert the IDENTITY, not two matching literals. */
130
172
  export { TRANSCRIPT_READ_MAX_BYTES };
131
173
 
174
+ /**
175
+ * The blocked-step marker, as § 3c of the process loop documents it. Anchored on
176
+ * the comment form with the id REQUIRED: this decides a terminal outcome and
177
+ * whether a budget is cleared, so prose merely mentioning the words must not
178
+ * remove a step from the open count.
179
+ */
180
+ const BLOCKED_BY_MARKER = /<!--\s*blocked-by:\s*\S[^>]*-->/;
181
+
182
+ /** Tail bound for `eventAlreadyLogged` — the line it seeks is near the end. */
183
+ const LEDGER_TAIL_MAX_BYTES = 512 * 1024;
184
+
132
185
  export const EVENTS_RELPATH = path.join(
133
186
  'agents',
134
187
  'runtime',
@@ -142,12 +195,70 @@ export const MAX_ITERATIONS = 25;
142
195
  export const WALL_CLOCK_CAP_MS = 4 * 60 * 60 * 1000;
143
196
  export const STALL_WINDOW = 3;
144
197
 
198
+ /**
199
+ * How many consecutive absent-roadmap fires confirm the absence.
200
+ *
201
+ * Two, because that is the smallest number that separates the two causes: a
202
+ * non-atomic rewrite is open for well under one turn, an archival lasts across
203
+ * them. Round 7 findings 1 and 4 measured the cost of treating one fire as
204
+ * confirmation — an unbounded budget reset, repeatable.
205
+ */
206
+ export const ABSENT_CONFIRM_FIRES = 2;
207
+
145
208
  export interface RunState {
146
209
  started_at: string;
147
210
  iterations: number;
148
211
  last_turn: number;
149
212
  /** Open-step count recorded at each engagement, newest last. */
150
213
  history: number[];
214
+ /**
215
+ * Set once the over-cap-transcript rung has reported this run inert.
216
+ *
217
+ * The rung fires on every stop for the rest of a long run, and one line saying
218
+ * so is information while a stream of identical lines is noise.
219
+ */
220
+ inert_reported?: boolean;
221
+ /**
222
+ * CONSECUTIVE stop fires on which the roadmap was unreadable — a run of
223
+ * absences, not a total, which is why the main path deletes it on a successful
224
+ * read. At `ABSENT_CONFIRM_FIRES` the budget is reclaimed; below it the state
225
+ * is left alone. The absent branch says why one fire is not enough.
226
+ */
227
+ absent_fires?: number;
228
+ /**
229
+ * The roadmap slug this state belongs to.
230
+ *
231
+ * Round 6 finding 2: the state is keyed on the session id alone, and one
232
+ * session may re-claim a different roadmap. A halt stamp is never cleared —
233
+ * only `complete` unlinks — so a later legitimate claim of roadmap B inherited
234
+ * A's stamp, emitted a halt line naming B without ever engaging, and reported
235
+ * A's iteration count under B's slug.
236
+ *
237
+ * Keying the FILE on the slug would have orphaned every state file in
238
+ * existence; recording the slug inside it does the same work and degrades
239
+ * cleanly. A mismatch is read as a fresh run, which is what it is.
240
+ *
241
+ * Optional so a state file written before this field parses unchanged; absent
242
+ * means "unknown roadmap" and is trusted, because the alternative is
243
+ * discarding a live budget on an upgrade.
244
+ */
245
+ roadmap?: string;
246
+ /**
247
+ * The roadmap file the counts in `history` were read from.
248
+ *
249
+ * Round 4 finding 2: `resolveRoadmap` re-chooses the source tree on every
250
+ * fire, and `ladder`'s stall test compares the counts for equality — so a run
251
+ * whose source changed mid-flight compared numbers from two different
252
+ * documents. Both directions were wrong: a differing earlier count from the
253
+ * other tree reset a genuine stall streak, and a coincidentally equal one
254
+ * contributed a false match toward `halt-stall`. Recording the source lets a
255
+ * change reset the window, which is the only honest reading — the counts
256
+ * before the change measured a different file.
257
+ *
258
+ * Optional so a state file written before this field parses unchanged; absent
259
+ * means "unknown source", which resets once and then tracks.
260
+ */
261
+ history_source?: string;
151
262
  /** ISO stamp of the most recent engagement — half of the duplicate key. */
152
263
  last_engaged_at?: string;
153
264
  /**
@@ -177,9 +288,30 @@ export function isDuplicateFire(
177
288
  turnOrdinal: number,
178
289
  openCount: number,
179
290
  nowMs: number,
291
+ roadmapSource?: string,
180
292
  ): boolean {
181
293
  if (state === null) return false;
182
294
  if (state.last_turn !== turnOrdinal) return false;
295
+ // Round 5 finding 6: this compared the recorded count against the fresh scan
296
+ // with no regard for which FILE either came from — the same cross-document
297
+ // comparison `history_source` exists to stop, in the one place that returns
298
+ // BLOCK without consuming an iteration or writing a line. A source change with
299
+ // a coincidentally equal count therefore erased a genuine engagement from the
300
+ // ledger the acceptance criteria are counted from. A changed source is never a
301
+ // duplicate fire.
302
+ // Round 7 finding 6: this read an ABSENT recorded source as a changed one, so
303
+ // the first fire after the upgrade that introduced the field failed to
304
+ // recognise a genuine duplicate — it consumed an iteration and appended an
305
+ // extra `engage` line to the ledger the acceptance criteria count from, which
306
+ // is the false count round 5 finding 6 added the guard to prevent. Both sides
307
+ // must be known before a difference means anything.
308
+ if (
309
+ state.history_source !== undefined &&
310
+ roadmapSource !== undefined &&
311
+ state.history_source !== roadmapSource
312
+ ) {
313
+ return false;
314
+ }
183
315
  if (state.history.length === 0 || state.history[state.history.length - 1] !== openCount) {
184
316
  return false;
185
317
  }
@@ -205,11 +337,20 @@ export interface ScanResult {
205
337
  export type LadderAction =
206
338
  | 'engage'
207
339
  | 'complete'
340
+ | 'blocked'
208
341
  | 'halt-max-iterations'
209
342
  | 'halt-wall-clock'
210
343
  | 'halt-stall';
211
344
 
212
345
  /** The terminal rungs — the set `RunState.halted` may hold. */
346
+ /**
347
+ * `halt-roadmap-absent` is deliberately NOT in this union and not in
348
+ * `LadderAction`. It is an event name the caller emits directly when the claimed
349
+ * roadmap is unreadable from the authoritative tree, and it CLEARS the state
350
+ * rather than stamping `halted` — widening the union would let `parseRecord`
351
+ * accept it as a `halted` value, which would mean a run whose budget was cleared
352
+ * also carried a halt stamp. The two are mutually exclusive by construction.
353
+ */
213
354
  export const HALT_ACTIONS: readonly LadderAction[] = [
214
355
  'halt-max-iterations',
215
356
  'halt-wall-clock',
@@ -272,7 +413,7 @@ export function scanOpenSteps(text: string): ScanResult {
272
413
  }
273
414
  const full = step.join('\n');
274
415
  const body = m[1] as string;
275
- if (body.includes('blocked-by:')) {
416
+ if (BLOCKED_BY_MARKER.test(body)) {
276
417
  blocked += 1;
277
418
  continue;
278
419
  }
@@ -329,6 +470,9 @@ export function ladder(
329
470
  state: RunState,
330
471
  openCount: number,
331
472
  nowMs: number,
473
+ // No default: `= 0` would silently restore the complete-instead-of-blocked
474
+ // behaviour for any caller that omitted it.
475
+ blockedCount: number,
332
476
  caps: { maxIterations: number; wallClockMs: number; stallWindow: number } = {
333
477
  maxIterations: MAX_ITERATIONS,
334
478
  wallClockMs: WALL_CLOCK_CAP_MS,
@@ -339,7 +483,10 @@ export function ladder(
339
483
  // halted run whose roadmap later reads zero-open does not report a
340
484
  // completion it never reached.
341
485
  if (state.halted) return state.halted;
342
- if (openCount === 0) return 'complete';
486
+ // `scanOpenSteps` EXCLUDES `blocked-by:` steps from `openCount`, so zero-open
487
+ // with blocked steps left is exhaustion of runnable work, not completion —
488
+ // ADR-235's own terminal outcome, and never a sixth halt.
489
+ if (openCount === 0) return blockedCount > 0 ? 'blocked' : 'complete';
343
490
  if (state.iterations >= caps.maxIterations) return 'halt-max-iterations';
344
491
  const started = Date.parse(state.started_at);
345
492
  if (Number.isFinite(started) && nowMs - started >= caps.wallClockMs) {
@@ -352,11 +499,133 @@ export function ladder(
352
499
  return 'engage';
353
500
  }
354
501
 
355
- export function stateRelPath(runId: string): string {
502
+ /**
503
+ * Where a run's state lives: keyed on the session AND the roadmap.
504
+ *
505
+ * ## Round 6 keyed it on the session alone, and round 7 killed that
506
+ *
507
+ * Round 6 finding 2 observed that one session may re-claim, so a halt stamped on
508
+ * roadmap A was inherited by a later claim of roadmap B. It fixed that by
509
+ * recording the slug INSIDE the file and reading a mismatch as a fresh run —
510
+ * declining to key the path on the slug because that "would have orphaned every
511
+ * state file in existence".
512
+ *
513
+ * Round 7 findings 2 and 3 showed the in-file discriminator trades one defect for
514
+ * two. The absent-roadmap branch never applied it, so it reported A's iteration
515
+ * count under B's slug and deleted A's live state; and on the main path, nulling
516
+ * `prev` on a mismatch means the next write OVERWRITES A's halt stamp — so a
517
+ * halted roadmap became re-engageable with a full budget by the detour of
518
+ * claiming something else once. "A halt must NOT clear it" was not durable.
519
+ *
520
+ * One file cannot hold two roadmaps' budgets, which is what both findings say from
521
+ * opposite ends. So the path carries the slug, and the orphaning objection is
522
+ * answered by MIGRATING rather than by avoiding: `readRunState` falls back to the
523
+ * legacy per-session path when the keyed one is absent, and adopts it only when its
524
+ * recorded roadmap is absent or equal. A live budget survives the upgrade; a
525
+ * foreign one is not inherited.
526
+ */
527
+ export function stateRelPath(runId: string, slug: string): string {
528
+ const safeRun = runId.replace(/[^A-Za-z0-9_-]/g, '_');
529
+ const safeSlug = slug.replace(/[^A-Za-z0-9_-]/g, '_');
530
+ return path.join(STATE_DIR_REL, `run-continuation-${safeRun}-${safeSlug}.json`);
531
+ }
532
+
533
+ /** The pre-round-7 path, read for migration and never written. */
534
+ export function legacyStateRelPath(runId: string): string {
356
535
  const safe = runId.replace(/[^A-Za-z0-9_-]/g, '_');
357
536
  return path.join(STATE_DIR_REL, `run-continuation-${safe}.json`);
358
537
  }
359
538
 
539
+ /**
540
+ * This run's state, from the keyed path or — once, on migration — from the legacy
541
+ * per-session one.
542
+ *
543
+ * Returns the state and the file it came from, because the caller has to know
544
+ * whether it is looking at a keyed file (safe to delete) or a legacy one shared
545
+ * with other roadmaps (never deleted; the keyed write supersedes it).
546
+ */
547
+ export function readRunState(
548
+ workspaceRoot: string,
549
+ runId: string,
550
+ slug: string,
551
+ ): { state: RunState | null; from: 'keyed' | 'legacy' | 'none'; keyedFile: string } {
552
+ const keyedFile = path.join(workspaceRoot, stateRelPath(runId, slug));
553
+ const keyed = readState(keyedFile);
554
+ if (keyed !== null) return { state: keyed, from: 'keyed', keyedFile };
555
+ if (fileExists(keyedFile)) {
556
+ // Present but unreadable. NOT a migration case: adopting the legacy file
557
+ // here would silently replace a corrupt budget with a foreign one.
558
+ return { state: null, from: 'keyed', keyedFile };
559
+ }
560
+ const legacy = readState(path.join(workspaceRoot, legacyStateRelPath(runId)));
561
+ if (legacy === null) return { state: null, from: 'none', keyedFile };
562
+ // Adopted only when the legacy file is not another roadmap's.
563
+ if (legacy.roadmap !== undefined && legacy.roadmap !== slug) {
564
+ return { state: null, from: 'none', keyedFile };
565
+ }
566
+ return { state: legacy, from: 'legacy', keyedFile };
567
+ }
568
+
569
+ /**
570
+ * The only writer of a run-state file. Never throws — the state is a budget, and
571
+ * throwing here would turn an observability concern into a turn-end failure.
572
+ *
573
+ * RETURNS whether the write landed, and the return is load-bearing exactly once:
574
+ * the engage path must not BLOCK on a counter it could not persist, because there
575
+ * the ladder cannot bound the loop. Every other caller ignores it (fail-open).
576
+ */
577
+ function writeState(file: string, state: RunState): boolean {
578
+ try {
579
+ fs.mkdirSync(path.dirname(file), { recursive: true });
580
+ fs.writeFileSync(file, `${JSON.stringify(state)}\n`, 'utf8');
581
+ return true;
582
+ } catch {
583
+ return false;
584
+ }
585
+ }
586
+
587
+ /**
588
+ * Clear a run's budget: the keyed file, and the legacy per-session file ONLY when
589
+ * this read adopted it. Both halves are load-bearing.
590
+ *
591
+ * Touching the legacy file at all: `readRunState` adopts it for a migrated run, so
592
+ * clearing only the keyed one made every clear a no-op there — `complete` stayed
593
+ * resumable and the absent-confirm clear became a repeating cycle.
594
+ *
595
+ * Gating on `adoptedLegacy` rather than on the adoption predicate: adoption and
596
+ * deletion want OPPOSITE defaults. A pre-round-7 file carries no `roadmap` field
597
+ * and is therefore adoptable by every slug — right for adopting, catastrophic for
598
+ * deleting, where one slug's clear would take another slug's live budget or halt
599
+ * stamp with it. Adoption is a fact this read already established; it is passed in
600
+ * rather than re-derived from a condition that cannot express it.
601
+ */
602
+ function clearRunState(
603
+ workspaceRoot: string,
604
+ runId: string,
605
+ keyedFile: string,
606
+ adoptedLegacy: boolean,
607
+ ): void {
608
+ try {
609
+ fs.rmSync(keyedFile, { force: true });
610
+ } catch {
611
+ /* fail-open */
612
+ }
613
+ if (!adoptedLegacy) return;
614
+ try {
615
+ fs.rmSync(path.join(workspaceRoot, legacyStateRelPath(runId)), { force: true });
616
+ } catch {
617
+ /* fail-open */
618
+ }
619
+ }
620
+
621
+ function fileExists(p: string): boolean {
622
+ try {
623
+ return fs.statSync(p).isFile();
624
+ } catch {
625
+ return false;
626
+ }
627
+ }
628
+
360
629
  function readState(file: string): RunState | null {
361
630
  try {
362
631
  const parsed: unknown = JSON.parse(fs.readFileSync(file, 'utf8'));
@@ -377,6 +646,21 @@ function readState(file: string): RunState | null {
377
646
  if (typeof o['last_engaged_at'] === 'string') {
378
647
  rec.last_engaged_at = o['last_engaged_at'];
379
648
  }
649
+ // Round-tripped, and it has to be: dropping it here would make the
650
+ // source look changed on EVERY fire, reset the window every time, and
651
+ // silently disable the stall detector the field was added to protect.
652
+ if (typeof o['history_source'] === 'string') {
653
+ rec.history_source = o['history_source'];
654
+ }
655
+ if (typeof o['roadmap'] === 'string') {
656
+ rec.roadmap = o['roadmap'];
657
+ }
658
+ if (typeof o['absent_fires'] === 'number' && Number.isFinite(o['absent_fires'])) {
659
+ rec.absent_fires = o['absent_fires'];
660
+ }
661
+ if (o['inert_reported'] === true) {
662
+ rec.inert_reported = true;
663
+ }
380
664
  // Validated against the halt set rather than accepted as any string:
381
665
  // an unrecognised value would be returned verbatim by `ladder` and
382
666
  // become an action no branch below handles.
@@ -394,8 +678,16 @@ function readState(file: string): RunState | null {
394
678
  }
395
679
 
396
680
  /**
397
- * The four fields that make an event's two-tree property checkable by a third
398
- * party holding nothing but the ledger line.
681
+ * The six fields that make an event's two-tree property checkable by a third
682
+ * party holding nothing but the ledger line. The caller adds a seventh,
683
+ * `roadmap_path`, which it knows and this function does not.
684
+ *
685
+ * Keep this count in step with the file header and the call-site comment. Round 2
686
+ * finding 4 caught the header saying four where the function emitted five; the
687
+ * edit that fixed it added `session_cwd` and left THIS heading saying five, which
688
+ * round 3 finding 3 then caught. Two rounds, same drift, opposite direction — the
689
+ * count is load-bearing because it is what a later edit dropping a field would
690
+ * have to contradict.
399
691
  *
400
692
  * The defect this concern shipped with was a writer and a reader resolving
401
693
  * DIFFERENT trees: `sessions:claim` wrote under `process.cwd()` (the operator's
@@ -410,31 +702,285 @@ function readState(file: string): RunState | null {
410
702
  * observation rather than a check on it; the falsifiable facts are concrete
411
703
  * (AI council 2026-08-19, 2/2 convergent):
412
704
  *
413
- * `git_dir !== git_common_dir` → this IS a linked worktree
705
+ * `session_root !== workspace_root` → writer and reader resolved different
706
+ * trees. After the fix this is the
707
+ * NORMAL healthy two-tree arrangement,
708
+ * not a defect: the claim now lives in
709
+ * the shared root, so the line existing
710
+ * at all with the two roots differing is
711
+ * the fix working. Round 2 finding 7
712
+ * caught the previous wording calling it
713
+ * "the shipped defect condition", which
714
+ * made a healthy engagement line read as
715
+ * evidence of the bug
716
+ * `git_dir !== git_common_dir` → the session's own tree IS a linked
717
+ * worktree. Only meaningful when both
718
+ * are non-empty — see the degenerate
719
+ * reading below
414
720
  * `claim_path` under `git_common_dir` → the contract crossed into the shared
415
- * root, which is the fix working
721
+ * root. Also gated on a non-empty
722
+ * `git_common_dir`: round 2 finding 2
723
+ * measured that on a non-repo line both
724
+ * git fields are `''` and every absolute
725
+ * path satisfies `startsWith('')`, so a
726
+ * reader applying the rule unguarded
727
+ * concludes the contract crossed a
728
+ * boundary the line says nothing about
729
+ * `session_cwd` → the raw directory the session reported
730
+ * before any resolution, so a reader can
731
+ * tell "same tree" from "could not
732
+ * resolve"
733
+ *
734
+ * ## The degenerate reading, stated so it cannot be mistaken for a negative
735
+ *
736
+ * Two of the three path relations above are only interpretable on a line whose
737
+ * git fields are non-empty. Empty means git could not be read from the session's
738
+ * root at all — a plain temp directory, a broken checkout — and that is an
739
+ * ABSENCE of evidence rather than evidence of a same-root run. A reader must
740
+ * check `git_common_dir !== ''` before applying either relation.
741
+ *
742
+ * `session_cwd` exists for the same reason, and it is a fact rather than an
743
+ * assertion. `session_checkout` degrades to `workspace_root` when its guards
744
+ * fail, and the raw `cwd` is what can make a degraded line distinguishable from a
745
+ * genuine same-tree one. A boolean `resolved: false` would have been the system
746
+ * under observation asserting its own health; the raw path is checkable.
747
+ *
748
+ * **The relation is SUFFICIENT, never necessary, and this file has now written it
749
+ * as necessary three rounds running.** A `session_cwd` outside `session_root`
750
+ * proves a fallback happened. The converse does not hold: round 4 finding 3 names
751
+ * three reachable inputs where the resolver falls back and the raw cwd still sits
752
+ * under `session_root` — a cwd inside a DIFFERENT repository nested under this
753
+ * checkout (a vendored clone, a test fixture) failing the identity check, a cwd
754
+ * that does not exist, and a relative cwd resolved against the reader's root. On
755
+ * those lines the provenance cannot tell a reader that a fallback occurred, and
756
+ * saying so is the honest bound rather than a wider claim that keeps being
757
+ * refuted.
758
+ *
759
+ * **The degradation this field was introduced for is gone, and the field stayed.**
760
+ * Round 2 added it because a session started from a SUBDIRECTORY of a worktree
761
+ * failed the resolver's checkout-root condition and collapsed onto the reader's
762
+ * root. Round 3 finding 2 then showed the field was BLIND to exactly that case in
763
+ * the layout it mattered in: with the worktree nested under the parent, the
764
+ * collapsed root, both git fields AND the raw cwd all read healthy same-tree.
765
+ * The fix was upstream — `session_checkout` now walks up to the nearest enclosing
766
+ * checkout root — so a subdirectory is no longer a degradation at all.
767
+ *
768
+ * What remains, and what the field is now for: a cwd inside a DIFFERENT
769
+ * repository, a cwd that does not exist, a cwd under no checkout root at all.
770
+ * Those still fall back, and on those lines `session_cwd` is still the only
771
+ * thing that says so. Two cases pin both directions.
416
772
  *
417
- * `process.cwd()` is deliberately NOT among them. The dispatcher chdirs into
418
- * `--project-dir` before the chain runs, so at concern time cwd equals
773
+ * ## Both git fields come from `session_root`, not from `workspace_root`
774
+ *
775
+ * The first shipped version of this function derived both of them from the
776
+ * READER's root, and R2 finding 1 killed it: in the arrangement documented
777
+ * three paragraphs up as the live one — the concern running under
778
+ * `--project-dir <parent checkout>` — `git_dir(parent)` and
779
+ * `git_common_dir(parent)` are the SAME path, so the discriminator above read
780
+ * FALSE for every real worktree-started run. The enrichment existed to make
781
+ * the two-tree property auditable and could not express it. `claim_path` could
782
+ * not rescue it either: the claim lands under `git_common_dir` in a plain
783
+ * checkout too, so that containment holds in both arrangements.
784
+ *
785
+ * `session_root` is the session's OWN checkout, resolved through the register's
786
+ * `session_checkout` — the same three-condition guard (existing directory,
787
+ * checkout root, same repository) rather than a second, divergent reading of
788
+ * `payload.cwd`. It degrades to `workspace_root` when any condition fails,
789
+ * which makes `session_root === workspace_root` mean "same tree, or unresolvable"
790
+ * and never a false two-tree claim.
791
+ *
792
+ * `process.cwd()` is deliberately NOT among the fields. The dispatcher chdirs
793
+ * into `--project-dir` before the chain runs, so at concern time cwd equals
419
794
  * `workspace_root` on every shipped path — a field that can never disagree with
420
795
  * another cannot falsify anything, and shipping it as provenance would be the
421
796
  * decorative-evidence shape this whole change exists to remove. Measured while
422
797
  * writing the test that asserted the opposite.
423
798
  *
424
- * Absolute paths, deliberately: a relative form is interpreted against whichever
425
- * root the reader happens to hold, which is the exact ambiguity being removed.
426
- * These are local filesystem paths in gitignored runtime state, never published
427
- * — the same posture the session register already takes.
799
+ * Absolute AND realpath-normalised, deliberately: a relative form is
800
+ * interpreted against whichever root the reader happens to hold, and a
801
+ * symlinked form defeats the containment test above. R2 finding 3 measured the
802
+ * second half — the git fields come back realpath-resolved from
803
+ * `_lib/git_common_dir.ts` while the first version passed the envelope string
804
+ * verbatim, so on macOS a healthy run wrote `workspace_root: /var/...` against
805
+ * `git_common_dir: /private/var/...` and the documented prefix test failed on
806
+ * it. Normalising here is the only place a ledger-only reader can inherit it
807
+ * from. These are local filesystem paths in gitignored runtime state, never
808
+ * published — the same posture the session register already takes.
809
+ */
810
+ function normalizeDir(p: string): string {
811
+ const abs = path.resolve(p);
812
+ try {
813
+ return fs.realpathSync(abs);
814
+ } catch {
815
+ return abs;
816
+ }
817
+ }
818
+
819
+ /**
820
+ * Realpath a file path without requiring the file to exist: the directory is
821
+ * normalised and the basename rejoined. The per-tree claim fallback names a
822
+ * path that may not have been written, and a missing file must not downgrade
823
+ * the whole line to un-normalised paths.
428
824
  */
429
- export function provenance(workspaceRoot: string, claimPath: string): JsonObject {
825
+ function normalizeFile(p: string): string {
826
+ const abs = path.resolve(p);
827
+ return path.join(normalizeDir(path.dirname(abs)), path.basename(abs));
828
+ }
829
+
830
+ export function provenance(
831
+ workspaceRoot: string,
832
+ claimPath: string,
833
+ sessionRoot: string,
834
+ sessionCwd: string,
835
+ ): JsonObject {
836
+ const reader = normalizeDir(workspaceRoot);
837
+ const writer = normalizeDir(sessionRoot);
430
838
  return {
431
- workspace_root: workspaceRoot,
432
- git_dir: git_dir(workspaceRoot) ?? '',
433
- git_common_dir: git_common_dir(workspaceRoot) ?? '',
434
- claim_path: claimPath,
839
+ workspace_root: reader,
840
+ session_root: writer,
841
+ // Empty when the host sent no `cwd` at all, which is a different fact
842
+ // from a `cwd` that failed resolution — the first says the host is
843
+ // silent, the second says the session sat somewhere `session_checkout`
844
+ // would not trust. Both are readable; neither is guessed.
845
+ //
846
+ // Trimmed with the SAME rule `session_checkout` applies to the same
847
+ // string (round 3 finding 6). Without it a whitespace-padded value
848
+ // validated fine there and was `path.resolve`d here against the reader's
849
+ // cwd, fabricating a path that is neither `session_root` nor under it —
850
+ // precisely the shape the docblock above says no healthy resolution
851
+ // produces, so a healthy line read as degraded. A whitespace-only value
852
+ // now reads as host silence, which is what it is.
853
+ session_cwd: sessionCwd.trim() === '' ? '' : normalizeDir(sessionCwd.trim()),
854
+ git_dir: git_dir(writer) ?? '',
855
+ git_common_dir: git_common_dir(writer) ?? '',
856
+ claim_path: normalizeFile(claimPath),
435
857
  };
436
858
  }
437
859
 
860
+ /**
861
+ * The roadmap file this run is executing, chosen by which tree OWNS the
862
+ * roadmaps directory — never by whether one particular file is present.
863
+ *
864
+ * ## The per-file fallback was wrong, and round 4 finding 1 named the cost
865
+ *
866
+ * The first version resolved per FILE: session copy if it exists, else the
867
+ * reader's. That reads correctly in a fresh worktree and breaks a finished run.
868
+ * `roadmap-progress-sync` mandates archival in the SAME change that closes the
869
+ * last step, so a completing run `git mv`s the file into
870
+ * `agents/roadmaps/archive/`. On the next stop fire the session copy is gone, the
871
+ * per-file rule silently fell back to the parent's UN-archived copy, read its
872
+ * still-open steps, and blocked with "continue with this step now" against a path
873
+ * that no longer exists in the tree the agent is editing — re-engaging to the
874
+ * iteration cap instead of ever reaching `complete`.
875
+ *
876
+ * Per DIRECTORY instead: if the session's tree has `agents/roadmaps/` at all,
877
+ * that tree is authoritative and a missing slug file means the roadmap is gone
878
+ * from this run — archived, renamed, or never created — which the caller's
879
+ * unreadable-roadmap branch already handles correctly by allowing the turn to
880
+ * end. The fallback survives for the case it was actually for: a session root
881
+ * that is not a checkout of this project layout.
882
+ *
883
+ * ## Slug containment
884
+ *
885
+ * The slug comes from a claim file, and `_read_claim_file` accepts any non-empty
886
+ * trimmed string. `claim_is_stale` in the same module treats containment as
887
+ * necessary before any read; round 4 finding 7 observed that this function did
888
+ * not, and that it now builds paths in TWO trees rather than one. A slug that
889
+ * escapes `agents/roadmaps/` returns `null` and the caller allows — refusing to
890
+ * name a file is the safe direction for a concern whose only power is refusing to
891
+ * end a turn.
892
+ */
893
+ function resolveRoadmap(
894
+ sessionRoot: string,
895
+ workspaceRoot: string,
896
+ slug: string,
897
+ ): string | null {
898
+ const dirRel = path.join('agents', 'roadmaps');
899
+ // The SAME normalisation `claim_is_stale` applies to the same claim string
900
+ // (round 5 finding 7). Without it a claim whose slug already carries the
901
+ // suffix rendered as live work in the register while this concern resolved
902
+ // `<slug>.md.md`, failed the read, and allowed every stop — two functions in
903
+ // one flow disagreeing about one string.
904
+ const stem = slug.trim().replace(/\.md$/, '');
905
+ const rel = path.join(dirRel, `${stem}.md`);
906
+ const norm = path.normalize(rel);
907
+ // Containment on the STRUCTURE, not on the characters: `path.join` has already
908
+ // collapsed traversal, so what matters is whether the result is still inside
909
+ // the roadmaps directory and names a file directly in it. Round 5 finding 10:
910
+ // a substring test for `..` also rejected a legitimate slug like
911
+ // `road-to-a..b`, which then silently allowed every stop.
912
+ if (path.dirname(norm) !== dirRel) return null;
913
+ if (sessionRoot !== workspaceRoot && isDirectory(path.join(sessionRoot, dirRel))) {
914
+ return path.join(sessionRoot, norm);
915
+ }
916
+ // Canonical, like every emitted path field (round 5 finding 9). The fallback
917
+ // branch joined the RAW reader root while `workspace_root` on the same line is
918
+ // normalised, so under a symlinked ancestor a reader's containment test failed
919
+ // on a perfectly healthy line — the regression round 2 finding 3 fixed for the
920
+ // other six fields, reintroduced on the seventh.
921
+ return path.join(normalizeDir(workspaceRoot), norm);
922
+ }
923
+
924
+ function isDirectory(p: string): boolean {
925
+ try {
926
+ return fs.statSync(p).isDirectory();
927
+ } catch {
928
+ return false;
929
+ }
930
+ }
931
+
932
+ /**
933
+ * True when a line with this event name, run id and roadmap is already in the
934
+ * ledger — the durable once-guard for the two rungs that cannot keep one in the
935
+ * state file: `halt-roadmap-absent` fires precisely when the state is absent or
936
+ * unparseable, and `blocked` CLEARS the state.
937
+ */
938
+ function eventAlreadyLogged(
939
+ workspaceRoot: string,
940
+ runId: string,
941
+ slug: string,
942
+ event: string,
943
+ ): boolean {
944
+ try {
945
+ const file = path.join(workspaceRoot, EVENTS_RELPATH);
946
+ const size = fs.statSync(file).size;
947
+ const start = Math.max(0, size - LEDGER_TAIL_MAX_BYTES);
948
+ const fd = fs.openSync(file, 'r');
949
+ let text: string;
950
+ try {
951
+ const buf = Buffer.alloc(size - start);
952
+ // The byte count is honoured: a short read, or a truncation between the
953
+ // `statSync` and here, would otherwise decode NUL padding into lines that
954
+ // fail `JSON.parse` and become a false negative.
955
+ const read = fs.readSync(fd, buf, 0, buf.length, start);
956
+ text = buf.subarray(0, read).toString('utf8');
957
+ } finally {
958
+ fs.closeSync(fd);
959
+ }
960
+ for (const line of text.split('\n')) {
961
+ if (line === '') continue;
962
+ let rec: unknown;
963
+ try {
964
+ rec = JSON.parse(line);
965
+ } catch {
966
+ // A partial first line from the tail cut, or a torn append.
967
+ continue;
968
+ }
969
+ if (rec === null || typeof rec !== 'object') continue;
970
+ const o = rec as Record<string, unknown>;
971
+ if (o['event'] === event && o['run_id'] === runId && o['roadmap'] === slug) {
972
+ return true;
973
+ }
974
+ }
975
+ return false;
976
+ } catch {
977
+ // No ledger, or unreadable: nothing proves the line was written, and
978
+ // emitting once more is the lesser failure than going silent on a rung this
979
+ // whole file exists to make visible.
980
+ return false;
981
+ }
982
+ }
983
+
438
984
  function appendEvent(workspaceRoot: string, record: JsonObject): void {
439
985
  try {
440
986
  const file = path.join(workspaceRoot, EVENTS_RELPATH);
@@ -490,17 +1036,162 @@ export function main(): number {
490
1036
  const claim = resolve_claim(workspaceRoot, sessionId || null);
491
1037
  if (claim === null) return EXIT_ALLOW;
492
1038
  const slug = claim.slug;
493
- // The two-root provenance, carried on every event this run emits. See
494
- // `provenance()` for why these three and not a boolean.
495
- const roots = provenance(workspaceRoot, claim.path);
496
1039
 
497
- const roadmapPath = path.join(workspaceRoot, 'agents', 'roadmaps', `${slug}.md`);
498
- let roadmapText: string;
499
- try {
500
- roadmapText = fs.readFileSync(roadmapPath, 'utf8');
501
- } catch {
502
- // A claim naming no readable roadmap is the stale-claim state the
503
- // session register already labels; not this concern's problem.
1040
+ // ── which tree's roadmap is the run ──────────────────────────────
1041
+ //
1042
+ // The SESSION's own checkout, falling back to the reader's root. In a plain
1043
+ // checkout the two are the same path and nothing about this changes; only
1044
+ // the two-tree case moves.
1045
+ //
1046
+ // R2 round 3 finding 1: this read the READER's tree unconditionally, and the
1047
+ // count it produces feeds the stall detector. In a worktree session the agent
1048
+ // flips checkboxes in the worktree copy while the parent copy does not move
1049
+ // until a merge — so the detector watched a file nobody was editing, the
1050
+ // count never changed, and after three engagements it emitted `halt-stall`
1051
+ // and declared a working run finished. The mechanism whose job is to detect a
1052
+ // stall was manufacturing one, which is the failure this file's own header
1053
+ // warns about in the abstract.
1054
+ //
1055
+ // AI council 2026-08-19, 2/2 convergent: resolve against the session
1056
+ // checkout, keep the fallback. Both seats also flagged, independently, that
1057
+ // `execution.mode` and the open-step count need not share an authority —
1058
+ // progress is inherently session-local while a mode could be read as
1059
+ // repo-wide policy. That choice is made HERE and stated rather than left
1060
+ // implicit: BOTH come from the session's file, because the session is
1061
+ // executing the file in its own tree and the parent's copy is simply an older
1062
+ // revision of the same document. A genuinely repo-wide mode policy would need
1063
+ // its own precedence rule, and inventing one on this evidence would be
1064
+ // designing for a case nobody has met.
1065
+ const sessionRoot = session_checkout(
1066
+ workspaceRoot,
1067
+ str(payload['cwd'] as JsonValue | undefined),
1068
+ );
1069
+ const roadmapPath = resolveRoadmap(sessionRoot, workspaceRoot, slug);
1070
+ const runId = deriveSessionKey(sessionId || 'unknown-session');
1071
+
1072
+ // Two locals rather than one plus a re-check: `roadmapFile` is only assigned
1073
+ // where the read succeeded, so the guard below narrows BOTH and everything
1074
+ // downstream holds a plain string. The alternative — re-testing `roadmapPath`
1075
+ // after the guard purely for the compiler — would be a branch that cannot be
1076
+ // reached and cannot be tested.
1077
+ let roadmapFile: string | null = null;
1078
+ let roadmapText: string | null = null;
1079
+ if (roadmapPath !== null) {
1080
+ try {
1081
+ roadmapText = fs.readFileSync(roadmapPath, 'utf8');
1082
+ roadmapFile = roadmapPath;
1083
+ } catch {
1084
+ roadmapText = null;
1085
+ }
1086
+ }
1087
+
1088
+ if (roadmapText === null || roadmapFile === null) {
1089
+ // ── the roadmap is not readable from the authoritative tree ──────
1090
+ //
1091
+ // Round 5 findings 2, 5 and 10, which are one shape seen three ways: this
1092
+ // returned a bare `EXIT_ALLOW` and left NOTHING behind.
1093
+ //
1094
+ // Finding 2 is the sharp end. `roadmap-progress-sync` mandates archival in
1095
+ // the same change that closes the last step, so a completing run flips the
1096
+ // last box and `git mv`s the file in one reply. The next fire found it
1097
+ // gone, allowed, and never reached the `complete` rung — so no line said
1098
+ // the run finished, AND `fs.rmSync(stateFile)` never ran. The state file
1099
+ // survived with `iterations` and `started_at` from the finished run, so a
1100
+ // later claim by the same session id began with part of the 25-iteration
1101
+ // budget spent and the 4 h clock already running — exactly what the
1102
+ // comment on that rung promises will not happen.
1103
+ //
1104
+ // A prior state file is the discriminator, and it is a fact rather than a
1105
+ // guess: the state only exists if THIS concern engaged on this roadmap
1106
+ // before, which it only does on an `autonomous` one. So:
1107
+ //
1108
+ // state present → this run was driven here and its roadmap is gone. Emit
1109
+ // the line, clear the budget, allow.
1110
+ // state absent → nothing was ever driven. Stay silent, which is the
1111
+ // truth rather than a gap: a `phase-checkpoints` claim
1112
+ // and a worktree branched before the roadmap landed
1113
+ // (finding 5) both land here, and neither is an event.
1114
+ //
1115
+ // What this does NOT claim is to tell archival from never-created. The
1116
+ // filesystem cannot: both are an absent file. The docblock on
1117
+ // `resolveRoadmap` used to assert the archival reading, and finding 5 was
1118
+ // right that it conflated the two — so the reading is now carried by the
1119
+ // state file, which actually knows.
1120
+ // Read THIS roadmap's state, keyed (round 7 finding 2). The previous version
1121
+ // read the session-keyed file with no roadmap discriminator, so after a
1122
+ // re-claim it reported roadmap A's iteration count under roadmap B's slug
1123
+ // and deleted A's live state — falsifying its own documented premise that
1124
+ // "state only exists if this concern engaged on THIS roadmap before".
1125
+ const runState = readRunState(workspaceRoot, runId, slug);
1126
+ const driven = runState.state;
1127
+ if (driven === null && runState.from === 'none') return EXIT_ALLOW;
1128
+
1129
+ // A HALT STAMP IS NEVER ERASED HERE (round 6 finding 1, high).
1130
+ //
1131
+ // A halted run has already recorded its own end, so there is nothing to
1132
+ // announce and nothing to reclaim. Silence and no write is the whole
1133
+ // correct action.
1134
+ if (driven?.halted !== undefined) return EXIT_ALLOW;
1135
+ const absenceRoots = provenance(
1136
+ workspaceRoot,
1137
+ claim.path,
1138
+ sessionRoot,
1139
+ str(payload['cwd'] as JsonValue | undefined),
1140
+ );
1141
+ // `null` where a traversal or malformed slug refused to name a file at
1142
+ // all (finding 10): the line still exists, so a refusal is no longer
1143
+ // indistinguishable from the concern never firing.
1144
+ absenceRoots['roadmap_path'] = roadmapPath;
1145
+
1146
+ // ── ONE absence is not a finished run (round 7 findings 1 and 4) ──
1147
+ //
1148
+ // Round 6 stopped this branch erasing a HALTED run's stamp and left the
1149
+ // larger half open: a run with a LIVE budget still had its state deleted on
1150
+ // a single unreadable fire. The triggers are ordinary — the agent checks out
1151
+ // a ref without the roadmap, a tool rewrites the file unlink-then-write, an
1152
+ // EACCES lands on the stop fire — and the consequence is unbounded: iteration
1153
+ // 20 becomes iteration 1 with a fresh 4 h clock, repeatable every time.
1154
+ //
1155
+ // Archival and a momentary read failure are indistinguishable from ONE fire.
1156
+ // They are not indistinguishable across two: a rewrite window is
1157
+ // sub-second, while an archived roadmap stays archived across turns. So the
1158
+ // absence is counted, the ledger line is written ONCE (on the first), and
1159
+ // the budget is reclaimed only after the absence has persisted.
1160
+ //
1161
+ // A state file that exists but cannot be parsed is never deleted here
1162
+ // (round 7 finding 4): the counter has nowhere to live, and deleting what we
1163
+ // could not read would erase an interrupted halt stamp — the exact leak the
1164
+ // presence check was added to close. The next healthy write supersedes it.
1165
+ const absentFires = (driven?.absent_fires ?? 0) + 1;
1166
+ // A readable state carries the counter; without one it has nowhere to live
1167
+ // and would recompute to 1 every fire, so the ledger answers instead.
1168
+ const firstAbsence =
1169
+ driven === null
1170
+ ? !eventAlreadyLogged(workspaceRoot, runId, slug, 'halt-roadmap-absent')
1171
+ : absentFires === 1;
1172
+ if (firstAbsence) {
1173
+ appendEvent(workspaceRoot, {
1174
+ event: 'halt-roadmap-absent',
1175
+ run_id: runId,
1176
+ roadmap: slug,
1177
+ // `null` when the state file exists but could not be parsed.
1178
+ // Reported as null rather than as 0, which would claim a fact the
1179
+ // file no longer carries.
1180
+ iterations: driven?.iterations ?? null,
1181
+ absent_fires: driven === null ? null : absentFires,
1182
+ at: new Date().toISOString(),
1183
+ ...absenceRoots,
1184
+ });
1185
+ }
1186
+ if (driven === null) return EXIT_ALLOW;
1187
+ if (absentFires < ABSENT_CONFIRM_FIRES) {
1188
+ driven.absent_fires = absentFires;
1189
+ writeState(runState.keyedFile, driven);
1190
+ return EXIT_ALLOW;
1191
+ }
1192
+ // Confirmed — see `clearRunState` for why the legacy file is in scope and
1193
+ // why only an adopted one is.
1194
+ clearRunState(workspaceRoot, runId, runState.keyedFile, runState.from === 'legacy');
504
1195
  return EXIT_ALLOW;
505
1196
  }
506
1197
  if (parseExecutionMode(roadmapText) !== 'autonomous') return EXIT_ALLOW;
@@ -521,7 +1212,43 @@ export function main(): number {
521
1212
  // toward allow is the same fail-open posture the gate takes on every
522
1213
  // unreadable-transcript case.
523
1214
  try {
524
- if (fs.statSync(transcriptPath).size > TRANSCRIPT_READ_MAX_BYTES) return EXIT_ALLOW;
1215
+ if (fs.statSync(transcriptPath).size > TRANSCRIPT_READ_MAX_BYTES) {
1216
+ // Round 7 finding 5: this returned ALLOW with no event and no state
1217
+ // change, so in the long-run regime this concern exists for — a 4 h cap
1218
+ // — a session whose transcript crosses the cap went silently inert for
1219
+ // the rest of the run while its budget stayed live. An inert mechanism
1220
+ // indistinguishable in the ledger from a healthy idle run is the exact
1221
+ // failure this whole roadmap is about, and the header claims every rung
1222
+ // is a named event.
1223
+ //
1224
+ // Emitted ONCE per run, keyed on the state, so a long inert tail does
1225
+ // not become a long stream of identical lines. No state file means no
1226
+ // run to report on — the same silence the contract-absent rung takes.
1227
+ const inert = readRunState(workspaceRoot, runId, slug);
1228
+ if (inert.state !== null && inert.state.inert_reported !== true) {
1229
+ // The header declares the provenance mandatory for EVERY event, and
1230
+ // a rung that exists to be distinguishable in the ledger is the last
1231
+ // one that should lack the fields a reader distinguishes runs by.
1232
+ const inertRoots = provenance(
1233
+ workspaceRoot,
1234
+ claim.path,
1235
+ sessionRoot,
1236
+ str(payload['cwd'] as JsonValue | undefined),
1237
+ );
1238
+ inertRoots['roadmap_path'] = roadmapFile;
1239
+ appendEvent(workspaceRoot, {
1240
+ event: 'inert-transcript-over-cap',
1241
+ run_id: runId,
1242
+ roadmap: slug,
1243
+ iterations: inert.state.iterations,
1244
+ at: new Date().toISOString(),
1245
+ ...inertRoots,
1246
+ });
1247
+ inert.state.inert_reported = true;
1248
+ writeState(inert.keyedFile, inert.state);
1249
+ }
1250
+ return EXIT_ALLOW;
1251
+ }
525
1252
  } catch {
526
1253
  return EXIT_ALLOW;
527
1254
  }
@@ -534,7 +1261,47 @@ export function main(): number {
534
1261
  return EXIT_ALLOW;
535
1262
  }
536
1263
 
537
- const runId = deriveSessionKey(sessionId || 'unknown-session');
1264
+
1265
+ // The two-tree provenance, carried on every event this run emits. See
1266
+ // `provenance()` for the six fields and why none of them is a boolean.
1267
+ //
1268
+ // Position, and what changed about the cost argument. Rounds 1 and 2 pushed
1269
+ // this call downward to stop a `phase-checkpoints` session, and then a
1270
+ // session with an over-cap transcript, from paying the git resolution for a
1271
+ // value the next line discarded.
1272
+ //
1273
+ // Round 3 finding 1 made half of that moot: the session root is now needed
1274
+ // ABOVE the mode gate, because it decides which tree's roadmap the mode is
1275
+ // read from. So the resolution happens early by necessity and this call
1276
+ // merely reuses it — the double resolution round 2 finding 5 accepted is
1277
+ // gone as a side effect, and nothing here re-derives it.
1278
+ //
1279
+ // What remains true, and round 3 finding 5 was right to catch the previous
1280
+ // comment claiming otherwise: two branches below still return without an
1281
+ // `appendEvent` — the duplicate-stop-fire guard and the failed-state-write
1282
+ // path — so this is not a position where every path writes a line. It is
1283
+ // simply the cheapest honest one now that the value is already in hand.
1284
+ //
1285
+ // The git resolution here duplicates the one `session_checkout` does to
1286
+ // validate the cwd, and that duplication is ACCEPTED rather than removed
1287
+ // (round 2 finding 5): collapsing it means widening a register API that
1288
+ // other callers use, to save six syscalls on a path that has already read a
1289
+ // roadmap file and a transcript tail. The two resolutions also answer
1290
+ // different questions — the guard asks whether the cwd belongs to this
1291
+ // repository, this asks what the session's own tree is.
1292
+ const roots = provenance(
1293
+ workspaceRoot,
1294
+ claim.path,
1295
+ sessionRoot,
1296
+ str(payload['cwd'] as JsonValue | undefined),
1297
+ );
1298
+ // Round 4 finding 4: the line carried six path fields and not the one path
1299
+ // the only tree-dependent NUMBER on it came from. On a line with
1300
+ // `session_root !== workspace_root` and `open: 2`, a reader could not tell
1301
+ // whether the count came from the session copy (the fix working) or from the
1302
+ // reader's after a fallback (the defect still live) — the exact ambiguity the
1303
+ // other six fields exist to remove.
1304
+ roots['roadmap_path'] = roadmapFile;
538
1305
 
539
1306
  // ── defer to the quality gate ────────────────────────────────────
540
1307
  if (refusedThisTurn(workspaceRoot, sessionId, turnOrdinal)) {
@@ -550,14 +1317,38 @@ export function main(): number {
550
1317
  }
551
1318
 
552
1319
  // ── state + duplicate-fire detection ─────────────────────────────
553
- const stateFile = path.join(workspaceRoot, stateRelPath(runId));
554
- const prev = readState(stateFile);
1320
+ //
1321
+ // A state file recorded against a DIFFERENT roadmap is not this run's state —
1322
+ // round 6 finding 2. The file is keyed on the session id alone and one session
1323
+ // may re-claim, so a halt stamped on roadmap A (never cleared: only `complete`
1324
+ // unlinks) was inherited by a later claim of roadmap B, which then emitted a
1325
+ // halt line naming B without ever engaging and reported A's iteration count
1326
+ // under B's slug. An absent `roadmap` field is trusted rather than discarded:
1327
+ // it means the state predates this field, and dropping a live budget on an
1328
+ // upgrade would be the worse error.
1329
+ // Keyed on (session, roadmap), with the legacy per-session file migrated once —
1330
+ // see `readRunState`. The in-file `roadmap` guard round 6 added is gone as a
1331
+ // BRANCH: it is now an identity, so a mismatch cannot reach this code at all,
1332
+ // and round 7 finding 3's "a mismatch nulls prev, so the next write overwrites
1333
+ // the other roadmap's halt stamp" has no path left.
1334
+ const mainState = readRunState(workspaceRoot, runId, slug);
1335
+ const stateFile = mainState.keyedFile;
1336
+ const prev = mainState.state;
555
1337
  const state: RunState = prev ?? {
556
1338
  started_at: new Date().toISOString(),
557
1339
  iterations: 0,
558
1340
  last_turn: -1,
559
1341
  history: [],
1342
+ roadmap: slug,
560
1343
  };
1344
+ // Stamped on an inherited state too, so the field fills in on first contact
1345
+ // rather than staying absent for the life of the run.
1346
+ state.roadmap = slug;
1347
+ // The reset the field's docblock promises. Reaching this line means the
1348
+ // roadmap read succeeded (the absent branch returns above), and the engage and
1349
+ // halt paths write THIS object back — so without the delete the counter is a
1350
+ // lifetime total and two separated absences reclaim a live budget.
1351
+ delete state.absent_fires;
561
1352
 
562
1353
  const scan = scanOpenSteps(roadmapText);
563
1354
 
@@ -566,34 +1357,70 @@ export function main(): number {
566
1357
  // very stop, and an allow on the re-fire would end the reply the block
567
1358
  // exists to continue. (An earlier draft allowed here — the smoke run
568
1359
  // caught it ending the run it had re-engaged one event earlier.)
569
- if (isDuplicateFire(prev, turnOrdinal, scan.open, Date.now())) {
1360
+ if (isDuplicateFire(prev, turnOrdinal, scan.open, Date.now(), roadmapFile)) {
570
1361
  process.stderr.write(_continuationText(slug, scan, state.iterations));
571
1362
  return EXIT_BLOCK;
572
1363
  }
573
1364
 
574
- const action = ladder(state, scan.open, Date.now());
1365
+ // The source-change reset runs HERE, before the ladder reads the history —
1366
+ // round 5 finding 1. It used to sit inside the engage branch, which is after
1367
+ // `ladder()` and after the non-engage branch returns, so on the ONE fire where
1368
+ // the source document changes the stall test still compared counts from two
1369
+ // different files. Three parent-copy readings at `open: 4` followed by a
1370
+ // session copy that also reads 4 produced `halt-stall` on the parent's numbers
1371
+ // and declared a working run finished — the precise failure the field's own
1372
+ // docblock claims to prevent, defeated by where the guard sat.
1373
+ if (state.history_source !== roadmapFile) {
1374
+ state.history = [];
1375
+ state.history_source = roadmapFile;
1376
+ }
1377
+
1378
+ const action = ladder(state, scan.open, Date.now(), scan.blocked);
575
1379
 
576
1380
  if (action !== 'engage') {
577
- appendEvent(workspaceRoot, {
578
- event: action,
579
- run_id: runId,
580
- roadmap: slug,
581
- turn: turnOrdinal,
582
- iterations: state.iterations,
583
- open: scan.open,
584
- blocked: scan.blocked,
585
- at: new Date().toISOString(),
586
- ...roots,
587
- });
1381
+ // A run that is ALREADY stamped emits nothing further — round 6 finding 6.
1382
+ // Once `halted` is set the state file is immortal for the session (only
1383
+ // `complete` unlinks it), so every later stop fire re-entered this branch
1384
+ // and appended another line with the same `run_id` and `iterations`: an
1385
+ // unbounded number of duplicate halt records in the ledger the acceptance
1386
+ // criteria's counts are derived from. The FIRST halt is the event; the rest
1387
+ // are the same fact re-stated.
1388
+ const alreadyStamped = prev?.halted !== undefined;
1389
+ // `blocked` can use neither `halted` (not in `HALT_ACTIONS`, by ADR-235) nor
1390
+ // a state field (the state is cleared below). The ledger survives both.
1391
+ const alreadyBlocked =
1392
+ action === 'blocked' && eventAlreadyLogged(workspaceRoot, runId, slug, 'blocked');
1393
+ if (!alreadyStamped && !alreadyBlocked) {
1394
+ appendEvent(workspaceRoot, {
1395
+ event: action,
1396
+ run_id: runId,
1397
+ roadmap: slug,
1398
+ turn: turnOrdinal,
1399
+ iterations: state.iterations,
1400
+ open: scan.open,
1401
+ blocked: scan.blocked,
1402
+ at: new Date().toISOString(),
1403
+ ...roots,
1404
+ });
1405
+ }
588
1406
  if (action === 'complete') {
589
1407
  // Only a COMPLETION clears the state, so a later run on the same
590
1408
  // roadmap (post-merge follow-up, re-claim) starts a fresh budget.
591
1409
  // A halt must NOT clear it — see `RunState.halted`.
592
- try {
593
- fs.rmSync(stateFile, { force: true });
594
- } catch {
595
- /* fail-open */
596
- }
1410
+ clearRunState(workspaceRoot, runId, stateFile, mainState.from === 'legacy');
1411
+ return EXIT_ALLOW;
1412
+ }
1413
+ if (action === 'blocked') {
1414
+ // CLEARED, like `complete`, and `runId` is why: it is
1415
+ // `deriveSessionKey(sessionId)` — a SESSION hash — so this same session
1416
+ // re-claiming this same roadmap reuses this same keyed file. Keeping the
1417
+ // state would hand the next run a spent budget, and since the ladder
1418
+ // tests `blocked` BEFORE the iteration cap, an unblocked roadmap could go
1419
+ // straight to `halt-max-iterations` without ever engaging.
1420
+ //
1421
+ // Safe only because the once-guard is the LEDGER: the next fire builds a
1422
+ // fresh state, reaches `blocked`, finds the line and stays silent.
1423
+ clearRunState(workspaceRoot, runId, stateFile, mainState.from === 'legacy');
597
1424
  return EXIT_ALLOW;
598
1425
  }
599
1426
  // A halt is recorded in place. A failed write is fail-open in the safe
@@ -601,12 +1428,7 @@ export function main(): number {
601
1428
  // worst case of a lost stamp is the pre-fix behaviour, never a block.
602
1429
  if (!state.halted) {
603
1430
  state.halted = action;
604
- try {
605
- fs.mkdirSync(path.dirname(stateFile), { recursive: true });
606
- fs.writeFileSync(stateFile, `${JSON.stringify(state)}\n`, 'utf8');
607
- } catch {
608
- /* fail-open */
609
- }
1431
+ writeState(stateFile, state);
610
1432
  }
611
1433
  return EXIT_ALLOW;
612
1434
  }
@@ -615,10 +1437,7 @@ export function main(): number {
615
1437
  state.last_turn = turnOrdinal;
616
1438
  state.history.push(scan.open);
617
1439
  state.last_engaged_at = new Date().toISOString();
618
- try {
619
- fs.mkdirSync(path.dirname(stateFile), { recursive: true });
620
- fs.writeFileSync(stateFile, `${JSON.stringify(state)}\n`, 'utf8');
621
- } catch {
1440
+ if (!writeState(stateFile, state)) {
622
1441
  // If the counter cannot persist, the ladder cannot bound the loop —
623
1442
  // so the loop must not run. Allowing here is the fail-open posture
624
1443
  // applied to the ONE branch where a lost write means an unbounded