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