@event4u/agent-config 14.1.0 → 14.4.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 (120) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/CHANGELOG.md +291 -181
  3. package/agents/templates/.ai-council.yml.example +35 -0
  4. package/dist/agent-src/contexts/execution/roadmap-process-loop.md +83 -12
  5. package/dist/agent-src/rules/roadmap-progress-sync.md +48 -4
  6. package/dist/agent-src/scripts/resume_probe.ts +154 -9
  7. package/dist/agent-src/scripts/roadmap_gates.ts +210 -6
  8. package/dist/agent-src/skills/roadmap-management/SKILL.md +9 -4
  9. package/dist/agent-src/templates/agent-settings.md +70 -0
  10. package/dist/agent-src/templates/agents/agent-project-settings.example.yml +1 -1
  11. package/dist/agent-src/templates/scripts/telemetry/remote.ts +571 -0
  12. package/dist/agent-src/templates/scripts/telemetry/settings.ts +222 -44
  13. package/dist/cli/python/workspace_drive.js +8 -3
  14. package/dist/cli/python/workspace_drive.js.map +1 -1
  15. package/dist/cli/python/workspace_hosts.js +16 -12
  16. package/dist/cli/python/workspace_hosts.js.map +1 -1
  17. package/dist/cli/registry.js +2 -0
  18. package/dist/cli/registry.js.map +1 -1
  19. package/dist/cli-delegate/{chunk-F3YUF7SA.js → chunk-3UCJWMMP.js} +1 -1
  20. package/dist/cli-delegate/chunk-F7DQWYR5.js +69 -0
  21. package/dist/cli-delegate/{chunk-7PZ4FSZB.js → chunk-FBKL6JWT.js} +61 -2
  22. package/dist/cli-delegate/{chunk-MNWRDMR5.js → chunk-PKOBQADP.js} +1 -1
  23. package/dist/cli-delegate/cmd_conformance.js +26 -11
  24. package/dist/cli-delegate/cmd_doctor.js +2 -2
  25. package/dist/cli-delegate/cmd_handoff.js +3 -3
  26. package/dist/cli-delegate/cmd_route_audit.js +2 -2
  27. package/dist/cli-delegate/cmd_settings_set.js +17 -3
  28. package/dist/discovery/deprecation-report.md +1 -1
  29. package/dist/discovery/discovery-manifest.json +4 -4
  30. package/dist/discovery/discovery-manifest.json.sha256 +1 -1
  31. package/dist/discovery/discovery-manifest.summary.md +1 -1
  32. package/dist/discovery/dormancy-report.md +1 -1
  33. package/dist/discovery/orphan-report.md +1 -1
  34. package/dist/discovery/packs.json +2 -2
  35. package/dist/discovery/trust-report.md +1 -1
  36. package/dist/discovery/workspaces.json +2 -2
  37. package/dist/hooks/dispatch.js +3773 -1731
  38. package/dist/mcp/registry-manifest.json +2 -2
  39. package/dist/mcp/server.json +2 -2
  40. package/dist/mcp/server.mjs +1070 -408
  41. package/dist/scripts/_lib/host_launch.js +66 -0
  42. package/dist/scripts/_lib/host_launch.js.map +1 -0
  43. package/dist/server/io/yamlIO.js +175 -3
  44. package/dist/server/io/yamlIO.js.map +1 -1
  45. package/dist/server/routes/wizard.js +20 -1
  46. package/dist/server/routes/wizard.js.map +1 -1
  47. package/dist/shared/settingsConsent.js +25 -0
  48. package/dist/shared/settingsConsent.js.map +1 -1
  49. package/docs/contracts/settings-classes.md +11 -0
  50. package/docs/guidelines/agent-infra/roadmap-progress-mechanics.md +2 -1
  51. package/package.json +1 -1
  52. package/src/agent-src/scripts/resume_probe.ts +154 -9
  53. package/src/agent-src/scripts/roadmap_gates.ts +210 -6
  54. package/src/agent-src/templates/scripts/telemetry/remote.ts +571 -0
  55. package/src/agent-src/templates/scripts/telemetry/settings.ts +222 -44
  56. package/src/config/estate-count-budget.json +120 -0
  57. package/src/config/evaluator-budgets.json +4 -4
  58. package/src/config/gate-coverage.yml +32 -4
  59. package/src/config/gate-violation-baselines.json +63 -63
  60. package/src/config/gitignore-block.txt +5 -0
  61. package/src/config/hook-latency-budget.json +40 -1
  62. package/src/config/hook-token-budget.json +22 -0
  63. package/src/config/rule-stub-ceilings.json +11 -3
  64. package/src/scripts/_dispatch.bash +42 -1
  65. package/src/scripts/_lib/council_fallback_posture.ts +81 -0
  66. package/src/scripts/_lib/council_fallback_wiring.ts +126 -0
  67. package/src/scripts/_lib/council_settings_block.ts +152 -0
  68. package/src/scripts/_lib/headless_invocation.ts +285 -0
  69. package/src/scripts/_lib/host_launch.ts +72 -0
  70. package/src/scripts/_lib/map_to_object.ts +16 -0
  71. package/src/scripts/_lib/roadmap_checkboxes.ts +88 -0
  72. package/src/scripts/_lib/run_checkpoint.ts +409 -0
  73. package/src/scripts/_lib/session_register.ts +32 -1
  74. package/src/scripts/_lib/skill_catalogue_series.ts +91 -0
  75. package/src/scripts/_lib/stdin.ts +35 -1
  76. package/src/scripts/_lib/turn_end_refusals.ts +12 -4
  77. package/src/scripts/_lib/unattended_guard.ts +371 -0
  78. package/src/scripts/ai_council/config.ts +27 -1
  79. package/src/scripts/ai_council/events_log.ts +12 -1
  80. package/src/scripts/ai_council/fallback_config.ts +114 -0
  81. package/src/scripts/ai_council/mid_flight_fallback.ts +295 -0
  82. package/src/scripts/ai_council/orchestrator.ts +251 -292
  83. package/src/scripts/ai_council/py_parity.ts +139 -0
  84. package/src/scripts/ai_council/response_render.ts +99 -0
  85. package/src/scripts/ai_council/spend_gate.ts +123 -0
  86. package/src/scripts/ai_council/transport_resolver.ts +81 -13
  87. package/src/scripts/bench_hook_injection.ts +100 -6
  88. package/src/scripts/bench_hook_latency.ts +442 -16
  89. package/src/scripts/check_estate_count.ts +924 -0
  90. package/src/scripts/compile_hook_manifest.ts +67 -0
  91. package/src/scripts/council_cli.ts +100 -125
  92. package/src/scripts/decision_memo.ts +325 -0
  93. package/src/scripts/dispatch_r2_reviewer.ts +169 -15
  94. package/src/scripts/hook_manifest.json +1 -0
  95. package/src/scripts/hook_manifest.yaml +172 -25
  96. package/src/scripts/hooks/concern_registry.ts +6 -0
  97. package/src/scripts/hooks/dispatch_hook.ts +183 -10
  98. package/src/scripts/hooks/dispatch_issues.ts +26 -0
  99. package/src/scripts/hooks/end_review_nudge_hook.ts +97 -24
  100. package/src/scripts/hooks/hook_stdin.ts +111 -5
  101. package/src/scripts/hooks/injection_budget.ts +595 -0
  102. package/src/scripts/hooks/payload_stub.ts +446 -0
  103. package/src/scripts/hooks/run_continuation_hook.ts +627 -0
  104. package/src/scripts/hooks/session_eol_hook.ts +52 -0
  105. package/src/scripts/hooks/skill_route_hook.ts +172 -11
  106. package/src/scripts/hooks/telemetry_usage_hook.ts +339 -0
  107. package/src/scripts/hooks/tool_result_bytes_hook.ts +27 -0
  108. package/src/scripts/hooks/turn_end_gate_hook.ts +45 -7
  109. package/src/scripts/hooks_doctor.ts +16 -2
  110. package/src/scripts/install-hooks.sh +26 -0
  111. package/src/scripts/interruption_report.ts +330 -13
  112. package/src/scripts/lint_hook_manifest.ts +167 -0
  113. package/src/scripts/roadmap_progress_hook.ts +254 -46
  114. package/src/scripts/run_supervise.ts +623 -0
  115. package/src/scripts/session_register_hook.ts +108 -8
  116. package/src/scripts/sessions_cli.ts +18 -6
  117. package/src/scripts/telemetry_disclosure_hook.ts +244 -0
  118. package/src/server/io/yamlIO.ts +167 -3
  119. package/src/shared/settingsConsent.ts +49 -2
  120. package/dist/cli-delegate/chunk-MMJL4TIX.js +0 -18
@@ -0,0 +1,627 @@
1
+ /**
2
+ * run_continuation — road-to-long-horizon-execution Phase 1 (H-1).
3
+ *
4
+ * The stop slot's first WORK-REMAINING concern. Every other stop concern is a
5
+ * quality or state concern; this one compares the session's contracted scope
6
+ * against roadmap checkbox state and, when contracted work remains, returns
7
+ * the dispatcher's block verdict with a continuation naming the next unchecked
8
+ * step and its `verify:` line. The dispatcher translates a stop-slot block to
9
+ * host exit 2, and the host feeds this concern's stderr back to the agent —
10
+ * that channel IS the re-engagement; no daemon, no second process.
11
+ *
12
+ * SHAPE PROVENANCE, and the three deliberate differences. The allow/deny-plus-
13
+ * continuation shape is claude-flow's `autopilotCheck` (commands/autopilot.ts
14
+ * @ fa13ee4, v3.38.12), verified at source in the roadmap's § 1. AC's version
15
+ * differs by design, not oversight:
16
+ *
17
+ * 1. The progress signal is the claimed roadmap's own checkboxes — the same
18
+ * artefact `roadmap-progress` already watches — never a parallel task
19
+ * file that can drift from the roadmap it mirrors.
20
+ * 2. Quality gates take precedence. Concerns run SEQUENTIALLY in chain
21
+ * order (dispatch_hook.ts § runner); this concern is registered AFTER
22
+ * `turn-end-gate`, and when the gate refused THIS turn (its refusal
23
+ * marker carries the turn ordinal) this concern defers. A turn refused
24
+ * for unverified claims is never re-engaged past the refusal — the
25
+ * upstream's naked "Do not stop until all are complete" is the
26
+ * anti-pattern this branch exists against.
27
+ * 3. Every re-engagement names the next step and its `verify:` line. A
28
+ * continuation that does not say what "continue" means just burns an
29
+ * iteration on re-orientation.
30
+ *
31
+ * CONTRACT GATING — when this concern is a no-op, which is almost always:
32
+ * · no roadmap claim for this session (`sessions:claim` never ran), or
33
+ * · the claimed roadmap's frontmatter `execution.mode` is anything but
34
+ * `autonomous` (absent, `interactive`, `phase-checkpoints` — a
35
+ * checkpointed run WANTS its phase-boundary conversation), or
36
+ * · replay mode, or the kill switch `AGENT_CONFIG_NO_RUN_CONTINUATION=1`.
37
+ * The claim file plus the mode field is the run contract this file consumes;
38
+ * it invents no second contract carrier.
39
+ *
40
+ * TERMINATION LADDER (Phase 1.1) — every rung a named event in
41
+ * `agents/runtime/state/run-continuation.jsonl`:
42
+ * contract absent → no-op (no event; the common case is silent)
43
+ * quality-gate refusal → defer (event: deferred-quality-gate)
44
+ * duplicate stop fire → repeat the BLOCK, no count (event: none —
45
+ * 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)
48
+ * iterations ≥ MAX (25) → allow (event: halt-max-iterations)
49
+ * wall clock ≥ cap (4 h) → allow (event: halt-wall-clock)
50
+ * stall (3 engagements, no
51
+ * checkbox delta) → allow (event: halt-stall)
52
+ * otherwise → BLOCK + continue (event: engage)
53
+ *
54
+ * Stall counts CONSECUTIVE ENGAGEMENTS whose open-step count did not move.
55
+ * The upstream stalls on 5-of-5 completed-count readings; AC's checkbox
56
+ * signal is finer-grained (a step in progress usually flips SOMETHING), so
57
+ * the default is tighter at 3 — pre-registered for revisit against Phase 4
58
+ * data in the roadmap, not tuned here.
59
+ *
60
+ * `[~]` (deferred) and `[-]` (cancelled) boxes are not open work — the
61
+ * vocabulary is `update_roadmap_progress.ts`'s. A step carrying a
62
+ * `blocked-by:` marker is open work this RUN cannot do, so it neither counts
63
+ * toward the open total nor gets named as the next step: re-engaging an
64
+ * agent into a step the roadmap itself declares blocked is a guaranteed
65
+ * stall, manufactured by the mechanism whose job is to detect one.
66
+ *
67
+ * FAIL-OPEN THROUGHOUT. severity: BLOCKING, fail_closed: false. Any read
68
+ * error, any malformed state, any surprise → EXIT_ALLOW. A continuation
69
+ * mechanism must never be the thing that blocks a stop for a reason nobody
70
+ * can see; the only BLOCK this file emits is the one that carries its own
71
+ * explanation on stderr.
72
+ *
73
+ * The severity word matters and this docblock had the wrong one. It read
74
+ * `advisory` — which the dispatcher enforces as a CEILING, downgrading any
75
+ * EXIT_BLOCK to WARN, so the whole mechanism was inert. That was round 1's
76
+ * critical finding; the manifest moved and this line did not, which round 3
77
+ * then caught as a doc contradicting the declaration it describes. Blocking
78
+ * and fail-open are not in tension: fail-open governs what happens when this
79
+ * file cannot decide, blocking governs what happens when it decides to
80
+ * continue.
81
+ */
82
+
83
+ import * as fs from 'node:fs';
84
+ import * as path from 'node:path';
85
+ import { fileURLToPath, pathToFileURL } from 'node:url';
86
+
87
+ import { unwrap, type JsonObject, type JsonValue } from './envelope.js';
88
+ import { readHookStdin } from './hook_stdin.js';
89
+ import { is_replay_mode } from './state_io.js';
90
+ import { readTranscriptTail } from './turn_end_gate_hook.js';
91
+ import { read_claimed_slug } from '../session_register_hook.js';
92
+ import {
93
+ deriveSessionKey,
94
+ parseRecord,
95
+ sessionRefusalFile,
96
+ } from '../_lib/turn_end_refusals.js';
97
+ import { phaseLines } from '../_lib/roadmap_checkboxes.js';
98
+
99
+ const EXIT_ALLOW = 0;
100
+ /** Dispatcher-internal block code; the dispatcher maps stop-slot 1 → host 2. */
101
+ const EXIT_BLOCK = 1;
102
+
103
+ /**
104
+ * The gate's cap, IMPORTED rather than restated — the two must agree by
105
+ * construction, not by two numbers that happened to match.
106
+ *
107
+ * R2 round 2, finding 1, and it was critical. This file carried its own 2 MB
108
+ * ("same cap the interruption ledger reads its tail under") while
109
+ * `turn_end_gate_hook` uses 8 MB, and `readTranscriptTail` returns
110
+ * `turnOrdinal: 0` over its cap WITHOUT throwing. So on any 2-8 MB transcript
111
+ * the gate computed ordinal N and wrote `refused_turn: N`, this concern
112
+ * computed 0, `refusedThisTurn` compared `N === 0`, the defer was skipped, and
113
+ * the concern BLOCKED a stop the quality gate had just refused — silently, and
114
+ * in exactly the long-running regime this roadmap targets. That is Risk 1 of
115
+ * the register ("quality gates outrank continuation, always") inverted.
116
+ *
117
+ * Sharing the constant is the fix; a matching literal would re-introduce the
118
+ * same defect the next time one side is tuned.
119
+ */
120
+ import { TRANSCRIPT_READ_MAX_BYTES } from './turn_end_gate_hook.js';
121
+
122
+ /** Re-exported so a test can assert the IDENTITY, not two matching literals. */
123
+ export { TRANSCRIPT_READ_MAX_BYTES };
124
+
125
+ export const EVENTS_RELPATH = path.join(
126
+ 'agents',
127
+ 'runtime',
128
+ 'state',
129
+ 'run-continuation.jsonl',
130
+ );
131
+ const STATE_DIR_REL = path.join('agents', 'runtime', 'state');
132
+
133
+ /** Ladder defaults. Phase 1.1 pre-registers all three for revisit, not tuning. */
134
+ export const MAX_ITERATIONS = 25;
135
+ export const WALL_CLOCK_CAP_MS = 4 * 60 * 60 * 1000;
136
+ export const STALL_WINDOW = 3;
137
+
138
+ export interface RunState {
139
+ started_at: string;
140
+ iterations: number;
141
+ last_turn: number;
142
+ /** Open-step count recorded at each engagement, newest last. */
143
+ history: number[];
144
+ /** ISO stamp of the most recent engagement — half of the duplicate key. */
145
+ last_engaged_at?: string;
146
+ /**
147
+ * Which halt rung ended this run's budget, if one did. Set instead of
148
+ * deleting the state file, and the difference is the whole point: a host
149
+ * may fire `stop` several times for one reply, and a deleted state makes
150
+ * the next fire read `prev === null`, build `iterations: 0` with a fresh
151
+ * `started_at`, and engage again — so the 25-iteration cap bounded a
152
+ * 25-block and the 4 h clock restarted on every halt. The R2 review found
153
+ * this; the roadmap's Risk 2 ("the agent loops forever on a roadmap it
154
+ * cannot finish") named these rungs as its mitigation.
155
+ */
156
+ halted?: LadderAction;
157
+ }
158
+
159
+ /**
160
+ * A host may fire `stop` more than once for one reply (the turn-end gate
161
+ * documents the same quirk). The duplicate key is ordinal + open-count +
162
+ * a short window, because the ordinal ALONE is not a turn identity here:
163
+ * a re-engaged reply often lands in the same user turn, so its ordinal is
164
+ * unchanged even though real work happened — the open count is what moved.
165
+ */
166
+ export const DUPLICATE_WINDOW_MS = 60 * 1000;
167
+
168
+ export function isDuplicateFire(
169
+ state: RunState | null,
170
+ turnOrdinal: number,
171
+ openCount: number,
172
+ nowMs: number,
173
+ ): boolean {
174
+ if (state === null) return false;
175
+ if (state.last_turn !== turnOrdinal) return false;
176
+ if (state.history.length === 0 || state.history[state.history.length - 1] !== openCount) {
177
+ return false;
178
+ }
179
+ const at = Date.parse(state.last_engaged_at ?? '');
180
+ return Number.isFinite(at) && nowMs - at < DUPLICATE_WINDOW_MS;
181
+ }
182
+
183
+ export interface NextStep {
184
+ /** The step's own line, checkbox stripped, truncated for the message. */
185
+ text: string;
186
+ /** The step's `verify:` command, when its line carries one. */
187
+ verify: string | null;
188
+ }
189
+
190
+ export interface ScanResult {
191
+ /** Open (`- [ ]`) steps that are not `blocked-by:`-marked. */
192
+ open: number;
193
+ /** Open steps excluded because their line carries `blocked-by:`. */
194
+ blocked: number;
195
+ next: NextStep | null;
196
+ }
197
+
198
+ export type LadderAction =
199
+ | 'engage'
200
+ | 'complete'
201
+ | 'halt-max-iterations'
202
+ | 'halt-wall-clock'
203
+ | 'halt-stall';
204
+
205
+ /** The terminal rungs — the set `RunState.halted` may hold. */
206
+ export const HALT_ACTIONS: readonly LadderAction[] = [
207
+ 'halt-max-iterations',
208
+ 'halt-wall-clock',
209
+ 'halt-stall',
210
+ ];
211
+
212
+ /**
213
+ * Frontmatter `execution.mode`, or null. Line-oriented on purpose: the
214
+ * frontmatter this repo writes is two levels deep at most, and a YAML parser
215
+ * on the Stop path is a dependency this read does not need.
216
+ */
217
+ export function parseExecutionMode(text: string): string | null {
218
+ if (!text.startsWith('---')) return null;
219
+ const end = text.indexOf('\n---', 3);
220
+ if (end === -1) return null;
221
+ const fm = text.slice(0, end);
222
+ if (!/^execution:\s*$/m.test(fm)) return null;
223
+ const m = fm.match(/^\s+mode:\s*(\S+)\s*$/m);
224
+ return m ? m[1]! : null;
225
+ }
226
+
227
+ /**
228
+ * An OPEN step. The mark set is the dashboard's, narrowed to the one mark that
229
+ * means "not done": `[~]` and `[-]` are parked by a human decision, and `[x]`
230
+ * is finished. The BULLET set is the dashboard's in full, because a `*` bullet
231
+ * is a legitimate authoring shape that used to read as zero open steps.
232
+ */
233
+ const OPEN_BOX = /^[ \t]*[-*][ \t]+\[ \][ \t]+(.*)$/;
234
+
235
+ /**
236
+ * Count open steps and pick the next one. `[~]` / `[-]` never match the open
237
+ * pattern; a `blocked-by:` marker on the step's own line excludes it from
238
+ * both the count and the pick (see the header for why).
239
+ */
240
+ export function scanOpenSteps(text: string): ScanResult {
241
+ let open = 0;
242
+ let blocked = 0;
243
+ let next: NextStep | null = null;
244
+ // Phase spans only. A `- [ ]` under `## Acceptance criteria`,
245
+ // `## Blockers` or `## Risk Register` is a criterion, a gate or a risk
246
+ // row — never an executable step, and re-engaging on one is a guaranteed
247
+ // stall. See `_lib/roadmap_checkboxes.ts`.
248
+ const lines = phaseLines(text);
249
+ for (let i = 0; i < lines.length; i++) {
250
+ const line = lines[i] as string;
251
+ const m = OPEN_BOX.exec(line);
252
+ if (!m) continue;
253
+ // A step spans its own line plus the indented continuation lines under
254
+ // it, and the `verify:` command is conventionally written on one of
255
+ // those rather than inline. Gathered up to the next checkbox or
256
+ // heading, so one step never absorbs the next one's verify line.
257
+ const step: string[] = [line];
258
+ for (let j = i + 1; j < lines.length; j++) {
259
+ const cont = lines[j] as string;
260
+ if (cont.trim() === '') break;
261
+ if (/^[ \t]*[-*][ \t]+\[/.test(cont)) break;
262
+ if (/^#{1,6}[ \t]/.test(cont)) break;
263
+ if (!/^[ \t]/.test(cont)) break;
264
+ step.push(cont);
265
+ }
266
+ const full = step.join('\n');
267
+ const body = m[1] as string;
268
+ if (body.includes('blocked-by:')) {
269
+ blocked += 1;
270
+ continue;
271
+ }
272
+ open += 1;
273
+ if (next === null) {
274
+ next = {
275
+ text: (() => {
276
+ const cleaned = body
277
+ .replace(/<!--.*?-->/g, '')
278
+ .replace(/\s+/g, ' ')
279
+ .trim();
280
+ return cleaned.length > 240 ? `${cleaned.slice(0, 237)}...` : cleaned;
281
+ })(),
282
+ verify: extractVerify(full),
283
+ };
284
+ }
285
+ }
286
+ return { open, blocked, next };
287
+ }
288
+
289
+ /**
290
+ * The step's `verify:` command, in either form the tree writes.
291
+ *
292
+ * R2 round 2, finding 10. Only the HTML-comment form was matched, and only on
293
+ * the step's OWN line — while 18 roadmaps, including both this branch ships,
294
+ * write the backticked form on a continuation line:
295
+ *
296
+ * - [ ] **1.0** do the thing
297
+ * `verify:` `./scripts-run src/scripts/lint_thing`
298
+ *
299
+ * So the continuation named a next step and omitted the command that proves it
300
+ * — and Risk 7 of the roadmap names exactly that command as its mitigation
301
+ * against a re-engagement that cannot check its own work.
302
+ *
303
+ * The comment form wins when both are present: it is the machine-readable one,
304
+ * and a step carrying both has a human-facing line and a tooling-facing line
305
+ * rather than two commands.
306
+ */
307
+ export function extractVerify(stepText: string): string | null {
308
+ const html = /<!--\s*verify:\s*(.*?)\s*-->/.exec(stepText);
309
+ if (html !== null) return (html[1] as string).trim() || null;
310
+ // `verify:` then the command, both backticked. The label's own backticks
311
+ // are optional because the tree carries `verify:` and `` `verify:` ``.
312
+ const backticked = /`?verify:`?\s*`([^`]+)`/.exec(stepText);
313
+ if (backticked !== null) return (backticked[1] as string).trim() || null;
314
+ return null;
315
+ }
316
+
317
+ /**
318
+ * The termination ladder, pure. `state` is the record BEFORE this stop;
319
+ * `openCount` is the scan of the claimed roadmap as it stands now.
320
+ */
321
+ export function ladder(
322
+ state: RunState,
323
+ openCount: number,
324
+ nowMs: number,
325
+ caps: { maxIterations: number; wallClockMs: number; stallWindow: number } = {
326
+ maxIterations: MAX_ITERATIONS,
327
+ wallClockMs: WALL_CLOCK_CAP_MS,
328
+ stallWindow: STALL_WINDOW,
329
+ },
330
+ ): LadderAction {
331
+ // A halt is terminal for this run id. Checked BEFORE `complete` so a
332
+ // halted run whose roadmap later reads zero-open does not report a
333
+ // completion it never reached.
334
+ if (state.halted) return state.halted;
335
+ if (openCount === 0) return 'complete';
336
+ if (state.iterations >= caps.maxIterations) return 'halt-max-iterations';
337
+ const started = Date.parse(state.started_at);
338
+ if (Number.isFinite(started) && nowMs - started >= caps.wallClockMs) {
339
+ return 'halt-wall-clock';
340
+ }
341
+ const tail = state.history.slice(-caps.stallWindow);
342
+ if (tail.length >= caps.stallWindow && tail.every((n) => n === openCount)) {
343
+ return 'halt-stall';
344
+ }
345
+ return 'engage';
346
+ }
347
+
348
+ export function stateRelPath(runId: string): string {
349
+ const safe = runId.replace(/[^A-Za-z0-9_-]/g, '_');
350
+ return path.join(STATE_DIR_REL, `run-continuation-${safe}.json`);
351
+ }
352
+
353
+ function readState(file: string): RunState | null {
354
+ try {
355
+ const parsed: unknown = JSON.parse(fs.readFileSync(file, 'utf8'));
356
+ if (parsed === null || typeof parsed !== 'object') return null;
357
+ const o = parsed as Record<string, unknown>;
358
+ if (typeof o['started_at'] !== 'string') return null;
359
+ if (typeof o['iterations'] !== 'number') return null;
360
+ if (typeof o['last_turn'] !== 'number') return null;
361
+ if (!Array.isArray(o['history'])) return null;
362
+ const rec: RunState = {
363
+ started_at: o['started_at'],
364
+ iterations: o['iterations'],
365
+ last_turn: o['last_turn'],
366
+ history: (o['history'] as unknown[]).filter(
367
+ (n): n is number => typeof n === 'number',
368
+ ),
369
+ };
370
+ if (typeof o['last_engaged_at'] === 'string') {
371
+ rec.last_engaged_at = o['last_engaged_at'];
372
+ }
373
+ // Validated against the halt set rather than accepted as any string:
374
+ // an unrecognised value would be returned verbatim by `ladder` and
375
+ // become an action no branch below handles.
376
+ const halted = o['halted'];
377
+ if (
378
+ typeof halted === 'string' &&
379
+ (HALT_ACTIONS as readonly string[]).includes(halted)
380
+ ) {
381
+ rec.halted = halted as LadderAction;
382
+ }
383
+ return rec;
384
+ } catch {
385
+ return null;
386
+ }
387
+ }
388
+
389
+ function appendEvent(workspaceRoot: string, record: JsonObject): void {
390
+ try {
391
+ const file = path.join(workspaceRoot, EVENTS_RELPATH);
392
+ fs.mkdirSync(path.dirname(file), { recursive: true });
393
+ fs.appendFileSync(file, `${JSON.stringify(record)}\n`, 'utf8');
394
+ } catch {
395
+ // The ledger is observability, never a reason to change the verdict.
396
+ }
397
+ }
398
+
399
+ /**
400
+ * True when `turn-end-gate` refused exactly this turn — the defer branch.
401
+ * Race-free by chain order: concerns run sequentially and this concern is
402
+ * registered after the gate, so the gate's marker for this turn is on disk
403
+ * before this read happens.
404
+ */
405
+ export function refusedThisTurn(
406
+ workspaceRoot: string,
407
+ sessionId: string,
408
+ turnOrdinal: number,
409
+ ): boolean {
410
+ try {
411
+ const file = sessionRefusalFile(workspaceRoot, deriveSessionKey(sessionId || 'unknown-session'));
412
+ const rec = parseRecord(fs.readFileSync(file, 'utf-8'));
413
+ return rec !== null && rec.refused_turn === turnOrdinal;
414
+ } catch {
415
+ return false;
416
+ }
417
+ }
418
+
419
+ function str(v: JsonValue | undefined): string {
420
+ return typeof v === 'string' ? v : '';
421
+ }
422
+
423
+ export function main(): number {
424
+ if (is_replay_mode()) return EXIT_ALLOW;
425
+ if (process.env['AGENT_CONFIG_NO_RUN_CONTINUATION'] === '1') return EXIT_ALLOW;
426
+
427
+ let envelope: JsonObject;
428
+ let payload: JsonObject;
429
+ try {
430
+ [envelope, payload] = unwrap(readHookStdin(), 'claude');
431
+ } catch {
432
+ return EXIT_ALLOW;
433
+ }
434
+
435
+ const workspaceRoot = str(envelope['workspace_root'] as JsonValue | undefined) || process.cwd();
436
+ const sessionId =
437
+ str(payload['session_id'] as JsonValue | undefined) ||
438
+ str(envelope['session_id'] as JsonValue | undefined);
439
+
440
+ // ── contract gate ────────────────────────────────────────────────
441
+ const slug = read_claimed_slug(workspaceRoot, sessionId || null);
442
+ if (slug === null) return EXIT_ALLOW;
443
+
444
+ const roadmapPath = path.join(workspaceRoot, 'agents', 'roadmaps', `${slug}.md`);
445
+ let roadmapText: string;
446
+ try {
447
+ roadmapText = fs.readFileSync(roadmapPath, 'utf8');
448
+ } catch {
449
+ // A claim naming no readable roadmap is the stale-claim state the
450
+ // session register already labels; not this concern's problem.
451
+ return EXIT_ALLOW;
452
+ }
453
+ if (parseExecutionMode(roadmapText) !== 'autonomous') return EXIT_ALLOW;
454
+
455
+ // ── turn identity ────────────────────────────────────────────────
456
+ const transcriptPath = str(
457
+ (payload['transcript_path'] ?? payload['transcriptPath']) as JsonValue | undefined,
458
+ );
459
+ if (!transcriptPath) return EXIT_ALLOW;
460
+ // Over the shared cap, `readTranscriptTail` returns `turnOrdinal: 0`
461
+ // without throwing — indistinguishable from a genuinely fresh session. The
462
+ // ordinal is the ONLY key the defer-to-the-gate check has, so an untrusted
463
+ // one must not be used to decide a block: allow instead.
464
+ //
465
+ // This is the residual half of R2 round 2's finding 1. Sharing the cap
466
+ // stops the two hooks disagreeing about the SAME transcript; this stops
467
+ // this concern acting on an ordinal neither of them could compute. Erring
468
+ // toward allow is the same fail-open posture the gate takes on every
469
+ // unreadable-transcript case.
470
+ try {
471
+ if (fs.statSync(transcriptPath).size > TRANSCRIPT_READ_MAX_BYTES) return EXIT_ALLOW;
472
+ } catch {
473
+ return EXIT_ALLOW;
474
+ }
475
+ let turnOrdinal = 0;
476
+ try {
477
+ turnOrdinal = readTranscriptTail(transcriptPath, {
478
+ maxBytes: TRANSCRIPT_READ_MAX_BYTES,
479
+ }).turnOrdinal;
480
+ } catch {
481
+ return EXIT_ALLOW;
482
+ }
483
+
484
+ const runId = deriveSessionKey(sessionId || 'unknown-session');
485
+
486
+ // ── defer to the quality gate ────────────────────────────────────
487
+ if (refusedThisTurn(workspaceRoot, sessionId, turnOrdinal)) {
488
+ appendEvent(workspaceRoot, {
489
+ event: 'deferred-quality-gate',
490
+ run_id: runId,
491
+ roadmap: slug,
492
+ turn: turnOrdinal,
493
+ at: new Date().toISOString(),
494
+ });
495
+ return EXIT_ALLOW;
496
+ }
497
+
498
+ // ── state + duplicate-fire detection ─────────────────────────────
499
+ const stateFile = path.join(workspaceRoot, stateRelPath(runId));
500
+ const prev = readState(stateFile);
501
+ const state: RunState = prev ?? {
502
+ started_at: new Date().toISOString(),
503
+ iterations: 0,
504
+ last_turn: -1,
505
+ history: [],
506
+ };
507
+
508
+ const scan = scanOpenSteps(roadmapText);
509
+
510
+ // A duplicate stop fire repeats the SAME verdict without consuming an
511
+ // iteration. It must repeat the BLOCK: this concern just blocked this
512
+ // very stop, and an allow on the re-fire would end the reply the block
513
+ // exists to continue. (An earlier draft allowed here — the smoke run
514
+ // caught it ending the run it had re-engaged one event earlier.)
515
+ if (isDuplicateFire(prev, turnOrdinal, scan.open, Date.now())) {
516
+ process.stderr.write(_continuationText(slug, scan, state.iterations));
517
+ return EXIT_BLOCK;
518
+ }
519
+
520
+ const action = ladder(state, scan.open, Date.now());
521
+
522
+ if (action !== 'engage') {
523
+ appendEvent(workspaceRoot, {
524
+ event: action,
525
+ run_id: runId,
526
+ roadmap: slug,
527
+ turn: turnOrdinal,
528
+ iterations: state.iterations,
529
+ open: scan.open,
530
+ blocked: scan.blocked,
531
+ at: new Date().toISOString(),
532
+ });
533
+ if (action === 'complete') {
534
+ // Only a COMPLETION clears the state, so a later run on the same
535
+ // roadmap (post-merge follow-up, re-claim) starts a fresh budget.
536
+ // A halt must NOT clear it — see `RunState.halted`.
537
+ try {
538
+ fs.rmSync(stateFile, { force: true });
539
+ } catch {
540
+ /* fail-open */
541
+ }
542
+ return EXIT_ALLOW;
543
+ }
544
+ // A halt is recorded in place. A failed write is fail-open in the safe
545
+ // direction here: the rung already decided to allow this stop, and the
546
+ // worst case of a lost stamp is the pre-fix behaviour, never a block.
547
+ if (!state.halted) {
548
+ state.halted = action;
549
+ try {
550
+ fs.mkdirSync(path.dirname(stateFile), { recursive: true });
551
+ fs.writeFileSync(stateFile, `${JSON.stringify(state)}\n`, 'utf8');
552
+ } catch {
553
+ /* fail-open */
554
+ }
555
+ }
556
+ return EXIT_ALLOW;
557
+ }
558
+
559
+ state.iterations += 1;
560
+ state.last_turn = turnOrdinal;
561
+ state.history.push(scan.open);
562
+ state.last_engaged_at = new Date().toISOString();
563
+ try {
564
+ fs.mkdirSync(path.dirname(stateFile), { recursive: true });
565
+ fs.writeFileSync(stateFile, `${JSON.stringify(state)}\n`, 'utf8');
566
+ } catch {
567
+ // If the counter cannot persist, the ladder cannot bound the loop —
568
+ // so the loop must not run. Allowing here is the fail-open posture
569
+ // applied to the ONE branch where a lost write means an unbounded
570
+ // re-engagement rather than a missed one.
571
+ return EXIT_ALLOW;
572
+ }
573
+
574
+ appendEvent(workspaceRoot, {
575
+ event: 'engage',
576
+ run_id: runId,
577
+ roadmap: slug,
578
+ turn: turnOrdinal,
579
+ iteration: state.iterations,
580
+ open: scan.open,
581
+ blocked: scan.blocked,
582
+ at: new Date().toISOString(),
583
+ });
584
+
585
+ process.stderr.write(_continuationText(slug, scan, state.iterations));
586
+ return EXIT_BLOCK;
587
+ }
588
+
589
+ function _continuationText(slug: string, scan: ScanResult, iteration: number): string {
590
+ const stepLine = scan.next
591
+ ? `Next step: ${scan.next.text}` +
592
+ (scan.next.verify ? `\n verify: ${scan.next.verify}` : '')
593
+ : 'Next step: (first open checkbox in the roadmap)';
594
+ return (
595
+ `run-continuation: the contracted roadmap \`${slug}\` has ${scan.open} open ` +
596
+ `step${scan.open === 1 ? '' : 's'}` +
597
+ (scan.blocked > 0 ? ` (+${scan.blocked} blocked-by, not yours this run)` : '') +
598
+ ` — iteration ${iteration}/${MAX_ITERATIONS}.\n` +
599
+ `${stepLine}\n` +
600
+ 'Continue with this step now: do the work, run its verify line, flip ' +
601
+ 'the box, then end the turn. Escalate instead of continuing ONLY for a ' +
602
+ 'locked decision class (high_impact / user_required) or a halt your ' +
603
+ 'contract names.\n'
604
+ );
605
+ }
606
+
607
+ // Bundle-safety: never auto-run when inlined into an esbuild bundle.
608
+ declare const __AGENT_CONFIG_BUNDLE__: boolean | undefined;
609
+ function _isCliEntry(): boolean {
610
+ if (typeof __AGENT_CONFIG_BUNDLE__ !== 'undefined' && __AGENT_CONFIG_BUNDLE__) {
611
+ return false;
612
+ }
613
+ if (process.argv[1] === undefined) return false;
614
+ const argvUrl = pathToFileURL(path.resolve(process.argv[1])).href;
615
+ if (import.meta.url === argvUrl) return true;
616
+ try {
617
+ const here = fs.realpathSync(fileURLToPath(import.meta.url));
618
+ const argv = fs.realpathSync(path.resolve(process.argv[1]));
619
+ return here === argv;
620
+ } catch {
621
+ return false;
622
+ }
623
+ }
624
+
625
+ if (_isCliEntry()) {
626
+ process.exit(main());
627
+ }