@event4u/agent-config 14.4.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.
- package/.claude-plugin/marketplace.json +1 -1
- package/CHANGELOG.md +132 -291
- package/dist/agent-src/commands/analyze/decision.md +7 -0
- package/dist/agent-src/commands/optimize/project.md +10 -0
- package/dist/agent-src/commands/roadmap/process-full.md +45 -3
- package/dist/agent-src/contexts/execution/roadmap-process-loop.md +130 -1
- package/dist/agent-src/rules/decision-revisit-gate.md +108 -7
- package/dist/agent-src/skills/adr-create/SKILL.md +25 -3
- package/dist/agent-src/skills/decision-review/SKILL.md +18 -0
- package/dist/agent-src/templates/agents/agent-project-settings.example.yml +1 -1
- package/dist/cli-delegate/{chunk-ZPSXJTLI.js → chunk-P6LQDIWK.js} +15 -1
- package/dist/cli-delegate/cmd_conformance.js +1 -1
- package/dist/cli-delegate/cmd_handoff.js +1 -1
- package/dist/cli-delegate/cmd_session_recycle.js +1 -1
- package/dist/discovery/deprecation-report.md +1 -1
- package/dist/discovery/discovery-manifest.json +8 -8
- package/dist/discovery/discovery-manifest.json.sha256 +1 -1
- package/dist/discovery/discovery-manifest.summary.md +1 -1
- package/dist/discovery/dormancy-report.md +1 -1
- package/dist/discovery/orphan-report.md +1 -1
- package/dist/discovery/packs.json +2 -2
- package/dist/discovery/trust-report.md +1 -1
- package/dist/discovery/workspaces.json +2 -2
- package/dist/hooks/dispatch.js +419 -94
- package/dist/mcp/registry-manifest.json +2 -2
- package/dist/mcp/server.json +2 -2
- package/dist/router.json +1 -1
- package/docs/guidelines/agent-infra/engineering-memory-data-format.md +49 -0
- package/package.json +1 -1
- package/src/config/estate-count-budget.json +36 -1
- package/src/config/pack-size-budget.json +3 -2
- package/src/scripts/_lib/layer_overlap_notice.ts +85 -0
- package/src/scripts/_lib/scope_guard.sh +62 -3
- package/src/scripts/_lib/subagent_capsule.ts +81 -1
- package/src/scripts/adr/regenerate_index.ts +13 -2
- package/src/scripts/adr_cite_check.ts +424 -0
- package/src/scripts/bench_hook_injection.ts +18 -1
- package/src/scripts/check_adr_frontmatter.ts +187 -1
- package/src/scripts/check_single_delivery.ts +473 -0
- package/src/scripts/hooks/host_semantics.ts +28 -0
- package/src/scripts/hooks/injection_budget.ts +152 -16
- package/src/scripts/hooks/run_continuation_hook.ts +918 -46
- package/src/scripts/install.sh +12 -2
- package/src/scripts/memory_consolidation.ts +189 -0
- package/src/scripts/memory_eviction.ts +384 -0
- package/src/scripts/report_layer_overlap.ts +72 -0
- package/src/scripts/report_memory_pointers.ts +622 -0
- package/src/scripts/session_register_hook.ts +144 -21
|
@@ -40,17 +40,62 @@
|
|
|
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
|
-
*
|
|
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
|
*
|
|
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.
|
|
98
|
+
*
|
|
54
99
|
* Stall counts CONSECUTIVE ENGAGEMENTS whose open-step count did not move.
|
|
55
100
|
* The upstream stalls on 5-of-5 completed-count readings; AC's checkbox
|
|
56
101
|
* signal is finer-grained (a step in progress usually flips SOMETHING), so
|
|
@@ -88,7 +133,8 @@ import { unwrap, type JsonObject, type JsonValue } from './envelope.js';
|
|
|
88
133
|
import { readHookStdin } from './hook_stdin.js';
|
|
89
134
|
import { is_replay_mode } from './state_io.js';
|
|
90
135
|
import { readTranscriptTail } from './turn_end_gate_hook.js';
|
|
91
|
-
import {
|
|
136
|
+
import { resolve_claim, session_checkout } from '../session_register_hook.js';
|
|
137
|
+
import { git_common_dir, git_dir } from '../_lib/git_common_dir.js';
|
|
92
138
|
import {
|
|
93
139
|
deriveSessionKey,
|
|
94
140
|
parseRecord,
|
|
@@ -122,6 +168,17 @@ import { TRANSCRIPT_READ_MAX_BYTES } from './turn_end_gate_hook.js';
|
|
|
122
168
|
/** Re-exported so a test can assert the IDENTITY, not two matching literals. */
|
|
123
169
|
export { TRANSCRIPT_READ_MAX_BYTES };
|
|
124
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
|
+
|
|
125
182
|
export const EVENTS_RELPATH = path.join(
|
|
126
183
|
'agents',
|
|
127
184
|
'runtime',
|
|
@@ -135,12 +192,70 @@ export const MAX_ITERATIONS = 25;
|
|
|
135
192
|
export const WALL_CLOCK_CAP_MS = 4 * 60 * 60 * 1000;
|
|
136
193
|
export const STALL_WINDOW = 3;
|
|
137
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
|
+
|
|
138
205
|
export interface RunState {
|
|
139
206
|
started_at: string;
|
|
140
207
|
iterations: number;
|
|
141
208
|
last_turn: number;
|
|
142
209
|
/** Open-step count recorded at each engagement, newest last. */
|
|
143
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;
|
|
144
259
|
/** ISO stamp of the most recent engagement — half of the duplicate key. */
|
|
145
260
|
last_engaged_at?: string;
|
|
146
261
|
/**
|
|
@@ -170,9 +285,30 @@ export function isDuplicateFire(
|
|
|
170
285
|
turnOrdinal: number,
|
|
171
286
|
openCount: number,
|
|
172
287
|
nowMs: number,
|
|
288
|
+
roadmapSource?: string,
|
|
173
289
|
): boolean {
|
|
174
290
|
if (state === null) return false;
|
|
175
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
|
+
}
|
|
176
312
|
if (state.history.length === 0 || state.history[state.history.length - 1] !== openCount) {
|
|
177
313
|
return false;
|
|
178
314
|
}
|
|
@@ -198,11 +334,20 @@ export interface ScanResult {
|
|
|
198
334
|
export type LadderAction =
|
|
199
335
|
| 'engage'
|
|
200
336
|
| 'complete'
|
|
337
|
+
| 'blocked'
|
|
201
338
|
| 'halt-max-iterations'
|
|
202
339
|
| 'halt-wall-clock'
|
|
203
340
|
| 'halt-stall';
|
|
204
341
|
|
|
205
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
|
+
*/
|
|
206
351
|
export const HALT_ACTIONS: readonly LadderAction[] = [
|
|
207
352
|
'halt-max-iterations',
|
|
208
353
|
'halt-wall-clock',
|
|
@@ -265,7 +410,7 @@ export function scanOpenSteps(text: string): ScanResult {
|
|
|
265
410
|
}
|
|
266
411
|
const full = step.join('\n');
|
|
267
412
|
const body = m[1] as string;
|
|
268
|
-
if (
|
|
413
|
+
if (BLOCKED_BY_MARKER.test(body)) {
|
|
269
414
|
blocked += 1;
|
|
270
415
|
continue;
|
|
271
416
|
}
|
|
@@ -322,6 +467,9 @@ export function ladder(
|
|
|
322
467
|
state: RunState,
|
|
323
468
|
openCount: number,
|
|
324
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,
|
|
325
473
|
caps: { maxIterations: number; wallClockMs: number; stallWindow: number } = {
|
|
326
474
|
maxIterations: MAX_ITERATIONS,
|
|
327
475
|
wallClockMs: WALL_CLOCK_CAP_MS,
|
|
@@ -332,7 +480,10 @@ export function ladder(
|
|
|
332
480
|
// halted run whose roadmap later reads zero-open does not report a
|
|
333
481
|
// completion it never reached.
|
|
334
482
|
if (state.halted) return state.halted;
|
|
335
|
-
|
|
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';
|
|
336
487
|
if (state.iterations >= caps.maxIterations) return 'halt-max-iterations';
|
|
337
488
|
const started = Date.parse(state.started_at);
|
|
338
489
|
if (Number.isFinite(started) && nowMs - started >= caps.wallClockMs) {
|
|
@@ -345,11 +496,133 @@ export function ladder(
|
|
|
345
496
|
return 'engage';
|
|
346
497
|
}
|
|
347
498
|
|
|
348
|
-
|
|
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 {
|
|
349
532
|
const safe = runId.replace(/[^A-Za-z0-9_-]/g, '_');
|
|
350
533
|
return path.join(STATE_DIR_REL, `run-continuation-${safe}.json`);
|
|
351
534
|
}
|
|
352
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
|
+
|
|
353
626
|
function readState(file: string): RunState | null {
|
|
354
627
|
try {
|
|
355
628
|
const parsed: unknown = JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
@@ -370,6 +643,21 @@ function readState(file: string): RunState | null {
|
|
|
370
643
|
if (typeof o['last_engaged_at'] === 'string') {
|
|
371
644
|
rec.last_engaged_at = o['last_engaged_at'];
|
|
372
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
|
+
}
|
|
373
661
|
// Validated against the halt set rather than accepted as any string:
|
|
374
662
|
// an unrecognised value would be returned verbatim by `ladder` and
|
|
375
663
|
// become an action no branch below handles.
|
|
@@ -386,6 +674,310 @@ function readState(file: string): RunState | null {
|
|
|
386
674
|
}
|
|
387
675
|
}
|
|
388
676
|
|
|
677
|
+
/**
|
|
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.
|
|
688
|
+
*
|
|
689
|
+
* The defect this concern shipped with was a writer and a reader resolving
|
|
690
|
+
* DIFFERENT trees: `sessions:claim` wrote under `process.cwd()` (the operator's
|
|
691
|
+
* worktree) while this concern read under `--project-dir` (the parent
|
|
692
|
+
* checkout), so the claim was never found and no event was written. An empty
|
|
693
|
+
* ledger looked exactly like a healthy idle run, which is how it survived a
|
|
694
|
+
* release.
|
|
695
|
+
*
|
|
696
|
+
* A reader therefore has to see, from the line alone, which trees were in play
|
|
697
|
+
* and where the contract was actually read out of. A boolean such as
|
|
698
|
+
* `worktree_started: true` would be another assertion by the system under
|
|
699
|
+
* observation rather than a check on it; the falsifiable facts are concrete
|
|
700
|
+
* (AI council 2026-08-19, 2/2 convergent):
|
|
701
|
+
*
|
|
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
|
|
717
|
+
* `claim_path` under `git_common_dir` → the contract crossed into the shared
|
|
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.
|
|
769
|
+
*
|
|
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
|
|
791
|
+
* `workspace_root` on every shipped path — a field that can never disagree with
|
|
792
|
+
* another cannot falsify anything, and shipping it as provenance would be the
|
|
793
|
+
* decorative-evidence shape this whole change exists to remove. Measured while
|
|
794
|
+
* writing the test that asserted the opposite.
|
|
795
|
+
*
|
|
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.
|
|
821
|
+
*/
|
|
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);
|
|
835
|
+
return {
|
|
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),
|
|
854
|
+
};
|
|
855
|
+
}
|
|
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
|
+
|
|
389
981
|
function appendEvent(workspaceRoot: string, record: JsonObject): void {
|
|
390
982
|
try {
|
|
391
983
|
const file = path.join(workspaceRoot, EVENTS_RELPATH);
|
|
@@ -438,16 +1030,165 @@ export function main(): number {
|
|
|
438
1030
|
str(envelope['session_id'] as JsonValue | undefined);
|
|
439
1031
|
|
|
440
1032
|
// ── contract gate ────────────────────────────────────────────────
|
|
441
|
-
const
|
|
442
|
-
if (
|
|
1033
|
+
const claim = resolve_claim(workspaceRoot, sessionId || null);
|
|
1034
|
+
if (claim === null) return EXIT_ALLOW;
|
|
1035
|
+
const slug = claim.slug;
|
|
443
1036
|
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
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');
|
|
451
1192
|
return EXIT_ALLOW;
|
|
452
1193
|
}
|
|
453
1194
|
if (parseExecutionMode(roadmapText) !== 'autonomous') return EXIT_ALLOW;
|
|
@@ -468,7 +1209,43 @@ export function main(): number {
|
|
|
468
1209
|
// toward allow is the same fail-open posture the gate takes on every
|
|
469
1210
|
// unreadable-transcript case.
|
|
470
1211
|
try {
|
|
471
|
-
if (fs.statSync(transcriptPath).size > TRANSCRIPT_READ_MAX_BYTES)
|
|
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
|
+
}
|
|
472
1249
|
} catch {
|
|
473
1250
|
return EXIT_ALLOW;
|
|
474
1251
|
}
|
|
@@ -481,7 +1258,47 @@ export function main(): number {
|
|
|
481
1258
|
return EXIT_ALLOW;
|
|
482
1259
|
}
|
|
483
1260
|
|
|
484
|
-
|
|
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;
|
|
485
1302
|
|
|
486
1303
|
// ── defer to the quality gate ────────────────────────────────────
|
|
487
1304
|
if (refusedThisTurn(workspaceRoot, sessionId, turnOrdinal)) {
|
|
@@ -491,19 +1308,44 @@ export function main(): number {
|
|
|
491
1308
|
roadmap: slug,
|
|
492
1309
|
turn: turnOrdinal,
|
|
493
1310
|
at: new Date().toISOString(),
|
|
1311
|
+
...roots,
|
|
494
1312
|
});
|
|
495
1313
|
return EXIT_ALLOW;
|
|
496
1314
|
}
|
|
497
1315
|
|
|
498
1316
|
// ── state + duplicate-fire detection ─────────────────────────────
|
|
499
|
-
|
|
500
|
-
|
|
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;
|
|
501
1334
|
const state: RunState = prev ?? {
|
|
502
1335
|
started_at: new Date().toISOString(),
|
|
503
1336
|
iterations: 0,
|
|
504
1337
|
last_turn: -1,
|
|
505
1338
|
history: [],
|
|
1339
|
+
roadmap: slug,
|
|
506
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;
|
|
507
1349
|
|
|
508
1350
|
const scan = scanOpenSteps(roadmapText);
|
|
509
1351
|
|
|
@@ -512,33 +1354,70 @@ export function main(): number {
|
|
|
512
1354
|
// very stop, and an allow on the re-fire would end the reply the block
|
|
513
1355
|
// exists to continue. (An earlier draft allowed here — the smoke run
|
|
514
1356
|
// caught it ending the run it had re-engaged one event earlier.)
|
|
515
|
-
if (isDuplicateFire(prev, turnOrdinal, scan.open, Date.now())) {
|
|
1357
|
+
if (isDuplicateFire(prev, turnOrdinal, scan.open, Date.now(), roadmapFile)) {
|
|
516
1358
|
process.stderr.write(_continuationText(slug, scan, state.iterations));
|
|
517
1359
|
return EXIT_BLOCK;
|
|
518
1360
|
}
|
|
519
1361
|
|
|
520
|
-
|
|
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);
|
|
521
1376
|
|
|
522
1377
|
if (action !== 'engage') {
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
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
|
+
}
|
|
533
1403
|
if (action === 'complete') {
|
|
534
1404
|
// Only a COMPLETION clears the state, so a later run on the same
|
|
535
1405
|
// roadmap (post-merge follow-up, re-claim) starts a fresh budget.
|
|
536
1406
|
// A halt must NOT clear it — see `RunState.halted`.
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
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');
|
|
542
1421
|
return EXIT_ALLOW;
|
|
543
1422
|
}
|
|
544
1423
|
// A halt is recorded in place. A failed write is fail-open in the safe
|
|
@@ -546,12 +1425,7 @@ export function main(): number {
|
|
|
546
1425
|
// worst case of a lost stamp is the pre-fix behaviour, never a block.
|
|
547
1426
|
if (!state.halted) {
|
|
548
1427
|
state.halted = action;
|
|
549
|
-
|
|
550
|
-
fs.mkdirSync(path.dirname(stateFile), { recursive: true });
|
|
551
|
-
fs.writeFileSync(stateFile, `${JSON.stringify(state)}\n`, 'utf8');
|
|
552
|
-
} catch {
|
|
553
|
-
/* fail-open */
|
|
554
|
-
}
|
|
1428
|
+
writeState(stateFile, state);
|
|
555
1429
|
}
|
|
556
1430
|
return EXIT_ALLOW;
|
|
557
1431
|
}
|
|
@@ -560,10 +1434,7 @@ export function main(): number {
|
|
|
560
1434
|
state.last_turn = turnOrdinal;
|
|
561
1435
|
state.history.push(scan.open);
|
|
562
1436
|
state.last_engaged_at = new Date().toISOString();
|
|
563
|
-
|
|
564
|
-
fs.mkdirSync(path.dirname(stateFile), { recursive: true });
|
|
565
|
-
fs.writeFileSync(stateFile, `${JSON.stringify(state)}\n`, 'utf8');
|
|
566
|
-
} catch {
|
|
1437
|
+
if (!writeState(stateFile, state)) {
|
|
567
1438
|
// If the counter cannot persist, the ladder cannot bound the loop —
|
|
568
1439
|
// so the loop must not run. Allowing here is the fail-open posture
|
|
569
1440
|
// applied to the ONE branch where a lost write means an unbounded
|
|
@@ -580,6 +1451,7 @@ export function main(): number {
|
|
|
580
1451
|
open: scan.open,
|
|
581
1452
|
blocked: scan.blocked,
|
|
582
1453
|
at: new Date().toISOString(),
|
|
1454
|
+
...roots,
|
|
583
1455
|
});
|
|
584
1456
|
|
|
585
1457
|
process.stderr.write(_continuationText(slug, scan, state.iterations));
|