@event4u/agent-config 14.2.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.
- package/.claude-plugin/marketplace.json +1 -1
- package/CHANGELOG.md +286 -218
- package/agents/templates/.ai-council.yml.example +35 -0
- package/dist/agent-src/contexts/execution/roadmap-process-loop.md +83 -12
- package/dist/agent-src/rules/roadmap-progress-sync.md +48 -4
- package/dist/agent-src/scripts/resume_probe.ts +154 -9
- package/dist/agent-src/scripts/roadmap_gates.ts +210 -6
- package/dist/agent-src/skills/roadmap-management/SKILL.md +9 -4
- package/dist/agent-src/templates/agent-settings.md +70 -0
- package/dist/agent-src/templates/agents/agent-project-settings.example.yml +1 -1
- package/dist/agent-src/templates/scripts/telemetry/remote.ts +571 -0
- package/dist/agent-src/templates/scripts/telemetry/settings.ts +222 -44
- package/dist/cli/python/workspace_drive.js +8 -3
- package/dist/cli/python/workspace_drive.js.map +1 -1
- package/dist/cli/python/workspace_hosts.js +16 -12
- package/dist/cli/python/workspace_hosts.js.map +1 -1
- package/dist/cli/registry.js +2 -0
- package/dist/cli/registry.js.map +1 -1
- package/dist/cli-delegate/{chunk-F3YUF7SA.js → chunk-3UCJWMMP.js} +1 -1
- package/dist/cli-delegate/chunk-F7DQWYR5.js +69 -0
- package/dist/cli-delegate/{chunk-7PZ4FSZB.js → chunk-FBKL6JWT.js} +61 -2
- package/dist/cli-delegate/{chunk-MNWRDMR5.js → chunk-PKOBQADP.js} +1 -1
- package/dist/cli-delegate/cmd_conformance.js +26 -11
- package/dist/cli-delegate/cmd_doctor.js +2 -2
- package/dist/cli-delegate/cmd_handoff.js +3 -3
- package/dist/cli-delegate/cmd_route_audit.js +2 -2
- package/dist/cli-delegate/cmd_settings_set.js +17 -3
- package/dist/discovery/deprecation-report.md +1 -1
- package/dist/discovery/discovery-manifest.json +4 -4
- 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 +3684 -1719
- package/dist/mcp/registry-manifest.json +2 -2
- package/dist/mcp/server.json +2 -2
- package/dist/mcp/server.mjs +1070 -408
- package/dist/scripts/_lib/host_launch.js +66 -0
- package/dist/scripts/_lib/host_launch.js.map +1 -0
- package/dist/server/io/yamlIO.js +175 -3
- package/dist/server/io/yamlIO.js.map +1 -1
- package/dist/server/routes/wizard.js +20 -1
- package/dist/server/routes/wizard.js.map +1 -1
- package/dist/shared/settingsConsent.js +25 -0
- package/dist/shared/settingsConsent.js.map +1 -1
- package/docs/contracts/settings-classes.md +11 -0
- package/docs/guidelines/agent-infra/roadmap-progress-mechanics.md +2 -1
- package/package.json +1 -1
- package/src/agent-src/scripts/resume_probe.ts +154 -9
- package/src/agent-src/scripts/roadmap_gates.ts +210 -6
- package/src/agent-src/templates/scripts/telemetry/remote.ts +571 -0
- package/src/agent-src/templates/scripts/telemetry/settings.ts +222 -44
- package/src/config/estate-count-budget.json +120 -0
- package/src/config/evaluator-budgets.json +4 -4
- package/src/config/gate-coverage.yml +32 -4
- package/src/config/gate-violation-baselines.json +63 -63
- package/src/config/gitignore-block.txt +5 -0
- package/src/config/hook-latency-budget.json +40 -1
- package/src/config/hook-token-budget.json +16 -0
- package/src/config/rule-stub-ceilings.json +11 -3
- package/src/scripts/_dispatch.bash +42 -1
- package/src/scripts/_lib/council_fallback_posture.ts +81 -0
- package/src/scripts/_lib/council_fallback_wiring.ts +126 -0
- package/src/scripts/_lib/council_settings_block.ts +152 -0
- package/src/scripts/_lib/headless_invocation.ts +285 -0
- package/src/scripts/_lib/host_launch.ts +72 -0
- package/src/scripts/_lib/map_to_object.ts +16 -0
- package/src/scripts/_lib/roadmap_checkboxes.ts +88 -0
- package/src/scripts/_lib/run_checkpoint.ts +409 -0
- package/src/scripts/_lib/session_register.ts +32 -1
- package/src/scripts/_lib/stdin.ts +35 -1
- package/src/scripts/_lib/turn_end_refusals.ts +12 -4
- package/src/scripts/_lib/unattended_guard.ts +371 -0
- package/src/scripts/ai_council/config.ts +27 -1
- package/src/scripts/ai_council/events_log.ts +12 -1
- package/src/scripts/ai_council/fallback_config.ts +114 -0
- package/src/scripts/ai_council/mid_flight_fallback.ts +295 -0
- package/src/scripts/ai_council/orchestrator.ts +251 -292
- package/src/scripts/ai_council/py_parity.ts +139 -0
- package/src/scripts/ai_council/response_render.ts +99 -0
- package/src/scripts/ai_council/spend_gate.ts +123 -0
- package/src/scripts/ai_council/transport_resolver.ts +81 -13
- package/src/scripts/bench_hook_injection.ts +100 -6
- package/src/scripts/bench_hook_latency.ts +442 -16
- package/src/scripts/check_estate_count.ts +924 -0
- package/src/scripts/compile_hook_manifest.ts +67 -0
- package/src/scripts/council_cli.ts +100 -125
- package/src/scripts/decision_memo.ts +325 -0
- package/src/scripts/hook_manifest.json +1 -0
- package/src/scripts/hook_manifest.yaml +172 -25
- package/src/scripts/hooks/concern_registry.ts +6 -0
- package/src/scripts/hooks/dispatch_hook.ts +183 -10
- package/src/scripts/hooks/dispatch_issues.ts +26 -0
- package/src/scripts/hooks/end_review_nudge_hook.ts +97 -24
- package/src/scripts/hooks/hook_stdin.ts +111 -5
- package/src/scripts/hooks/injection_budget.ts +595 -0
- package/src/scripts/hooks/payload_stub.ts +446 -0
- package/src/scripts/hooks/run_continuation_hook.ts +627 -0
- package/src/scripts/hooks/session_eol_hook.ts +52 -0
- package/src/scripts/hooks/telemetry_usage_hook.ts +339 -0
- package/src/scripts/hooks/tool_result_bytes_hook.ts +27 -0
- package/src/scripts/hooks/turn_end_gate_hook.ts +45 -7
- package/src/scripts/hooks_doctor.ts +16 -2
- package/src/scripts/interruption_report.ts +330 -13
- package/src/scripts/lint_hook_manifest.ts +167 -0
- package/src/scripts/roadmap_progress_hook.ts +254 -46
- package/src/scripts/run_supervise.ts +623 -0
- package/src/scripts/session_register_hook.ts +108 -8
- package/src/scripts/sessions_cli.ts +18 -6
- package/src/scripts/telemetry_disclosure_hook.ts +244 -0
- package/src/server/io/yamlIO.ts +167 -3
- package/src/shared/settingsConsent.ts +49 -2
- package/dist/cli-delegate/chunk-MMJL4TIX.js +0 -18
|
@@ -0,0 +1,623 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* run_supervise — the out-of-process watcher for runs whose session died.
|
|
3
|
+
*
|
|
4
|
+
* road-to-long-horizon-execution Phase 3.1 (sequencing UOTL Phase 6.2).
|
|
5
|
+
*
|
|
6
|
+
* ## The defect
|
|
7
|
+
*
|
|
8
|
+
* A session dies with its terminal. Nothing anywhere has the job of noticing
|
|
9
|
+
* that a claimed run stopped moving, so a roadmap 40 % done and a roadmap
|
|
10
|
+
* finished look identical from outside the dead session.
|
|
11
|
+
*
|
|
12
|
+
* ## What this is, and what it deliberately is not
|
|
13
|
+
*
|
|
14
|
+
* A FOREGROUND loop, not a daemon. The roadmap says so, and the reason is
|
|
15
|
+
* falsifiability: a design that has never been watched running is not a
|
|
16
|
+
* design that has been tested, and a v1 daemon hides its own behaviour behind
|
|
17
|
+
* a log file. A foreground loop can be read while it works.
|
|
18
|
+
*
|
|
19
|
+
* **It never merges.** The reference design this borrows the loop shape from
|
|
20
|
+
* also auto-merges ready worktrees; that borrowing is REJECTED, by name, in
|
|
21
|
+
* the roadmap's harvest section. Merge stays human and conversational. This
|
|
22
|
+
* process may report, and — only with `--relaunch` — start a fresh session.
|
|
23
|
+
* It may never merge, push, or close anything.
|
|
24
|
+
*
|
|
25
|
+
* **It reports by default.** Relaunching spawns a coding agent that spends
|
|
26
|
+
* tokens without a human watching, so the acting path is behind an explicit
|
|
27
|
+
* flag rather than behind a default that a stray invocation trips.
|
|
28
|
+
*
|
|
29
|
+
* ## Liveness, and an honest departure from the borrowed design
|
|
30
|
+
*
|
|
31
|
+
* The reference resumes crashed sessions by PID liveness. This register has
|
|
32
|
+
* no PID field — liveness here is the heartbeat (`last_seen`) against a
|
|
33
|
+
* per-platform TTL, which is what the register actually maintains. That is a
|
|
34
|
+
* WEAKER signal in one specific way and it is stated rather than papered
|
|
35
|
+
* over: a session killed seconds ago still reads live until its TTL expires,
|
|
36
|
+
* so this watcher is late by up to one TTL rather than immediate. It is also
|
|
37
|
+
* STRONGER in another: a wedged session that still holds its PID reads dead
|
|
38
|
+
* here once it stops beating, and reads alive to a PID check forever.
|
|
39
|
+
*/
|
|
40
|
+
|
|
41
|
+
import * as fs from 'node:fs';
|
|
42
|
+
import * as path from 'node:path';
|
|
43
|
+
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
44
|
+
|
|
45
|
+
import { is_expired, register_dir, type SessionRecord } from './_lib/session_register.js';
|
|
46
|
+
import {
|
|
47
|
+
countRoadmap,
|
|
48
|
+
latestCheckpointFor,
|
|
49
|
+
readCheckpoint,
|
|
50
|
+
readHead,
|
|
51
|
+
roadmapPath,
|
|
52
|
+
} from './_lib/run_checkpoint.js';
|
|
53
|
+
import { readBudget } from './_lib/unattended_guard.js';
|
|
54
|
+
import {
|
|
55
|
+
LIVE_SPAWN_REFUSAL,
|
|
56
|
+
planResume,
|
|
57
|
+
renderResumePlans,
|
|
58
|
+
type ResumePlan,
|
|
59
|
+
} from './_lib/headless_invocation.js';
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* UOTL 6.2 verbatim: at most three relaunches per run — and "run" means the
|
|
63
|
+
* roadmap across every session that worked it, not one session id. See
|
|
64
|
+
* {@link RelaunchLedger}.
|
|
65
|
+
*/
|
|
66
|
+
export const MAX_RELAUNCHES_PER_RUN = 3;
|
|
67
|
+
|
|
68
|
+
/** The emergency stop. Same switch the orchestration layer already honours. */
|
|
69
|
+
export const HALT_ENV = 'AGENT_CONFIG_ORCHESTRATION_HALT';
|
|
70
|
+
|
|
71
|
+
export const SUPERVISE_STATE_REL = path.join(
|
|
72
|
+
'agents',
|
|
73
|
+
'runtime',
|
|
74
|
+
'state',
|
|
75
|
+
'supervise-relaunches.json',
|
|
76
|
+
);
|
|
77
|
+
|
|
78
|
+
export type Disposition =
|
|
79
|
+
| 'alive'
|
|
80
|
+
| 'no-roadmap'
|
|
81
|
+
| 'roadmap-unreadable'
|
|
82
|
+
| 'complete'
|
|
83
|
+
| 'budget-exhausted'
|
|
84
|
+
| 'relaunchable';
|
|
85
|
+
|
|
86
|
+
export interface Candidate {
|
|
87
|
+
readonly session_id: string;
|
|
88
|
+
readonly roadmap: string | null;
|
|
89
|
+
readonly worktree: string;
|
|
90
|
+
/**
|
|
91
|
+
* Host platform, carried through from the register record.
|
|
92
|
+
*
|
|
93
|
+
* Needed by `--print-relaunch`: the resume command's shape is a property of
|
|
94
|
+
* the HOST the dead session ran on, and only the record knows which that
|
|
95
|
+
* was. Guessing the current process's host would build a `claude` command
|
|
96
|
+
* for a run that died under `codex`.
|
|
97
|
+
*/
|
|
98
|
+
readonly platform: string;
|
|
99
|
+
readonly disposition: Disposition;
|
|
100
|
+
readonly open_steps: number | null;
|
|
101
|
+
readonly relaunches: number;
|
|
102
|
+
/** One sentence a human can act on without opening anything. */
|
|
103
|
+
readonly reason: string;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** Per-run relaunch counts. A plain object so the file stays readable by eye. */
|
|
107
|
+
/**
|
|
108
|
+
* Relaunch counts, keyed by ROADMAP SLUG — never by session id.
|
|
109
|
+
*
|
|
110
|
+
* R2 round 2, finding 9. The cap is documented as "at most three relaunches
|
|
111
|
+
* per RUN", and a run spans generations by definition: relaunching produces a
|
|
112
|
+
* NEW session id, so a session-keyed ledger handed every generation a fresh
|
|
113
|
+
* budget of three and the cap could never bind. The roadmap is what identifies
|
|
114
|
+
* a run across its generations — the same key `latestCheckpointFor` uses, and
|
|
115
|
+
* for the same reason.
|
|
116
|
+
*
|
|
117
|
+
* Total over the relaunchable set: a record with no roadmap slug is classified
|
|
118
|
+
* `no-roadmap` before the cap is consulted, so there is no relaunchable run
|
|
119
|
+
* this key cannot name.
|
|
120
|
+
*
|
|
121
|
+
* THE COUNTER IS CLEARED WHEN THE ROADMAP COMPLETES, and that half is what
|
|
122
|
+
* makes the key correct rather than merely different. R2 round 3, finding 6:
|
|
123
|
+
* a roadmap-keyed counter with no reset swaps a cap that never binds for one
|
|
124
|
+
* that never releases — three deaths in March would refuse a relaunch in
|
|
125
|
+
* September for a run that has nothing to do with them. "Per run" needs both
|
|
126
|
+
* a key that spans a run's generations AND a boundary where one run ends, and
|
|
127
|
+
* `disposition: complete` is that boundary: zero open steps means the work the
|
|
128
|
+
* counter was counting attempts at is done.
|
|
129
|
+
*
|
|
130
|
+
* `clearCompleted` is the writer. It is called from `digest`, which is a
|
|
131
|
+
* read-report — so the clear is stated where it happens rather than hidden in
|
|
132
|
+
* a getter, and a caller that only classifies never mutates.
|
|
133
|
+
*/
|
|
134
|
+
export type RelaunchLedger = Record<string, number>;
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Drop the counters of every roadmap whose run is complete.
|
|
138
|
+
*
|
|
139
|
+
* Returns a NEW ledger; the caller decides whether to persist it. Separating
|
|
140
|
+
* the decision from the write keeps `classify` pure — it is called from a
|
|
141
|
+
* digest, from tests, and (one day) from the spawn, and only one of those
|
|
142
|
+
* should be writing state.
|
|
143
|
+
*/
|
|
144
|
+
export function clearCompleted(
|
|
145
|
+
ledger: RelaunchLedger,
|
|
146
|
+
candidates: readonly Candidate[],
|
|
147
|
+
): RelaunchLedger {
|
|
148
|
+
const done = new Set(
|
|
149
|
+
candidates.filter((c) => c.disposition === 'complete' && c.roadmap !== null).map((c) => c.roadmap as string),
|
|
150
|
+
);
|
|
151
|
+
const out: RelaunchLedger = {};
|
|
152
|
+
for (const [k, v] of Object.entries(ledger)) {
|
|
153
|
+
if (!done.has(k)) out[k] = v;
|
|
154
|
+
}
|
|
155
|
+
return out;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
export function readLedger(repoRoot: string): RelaunchLedger {
|
|
159
|
+
try {
|
|
160
|
+
const parsed: unknown = JSON.parse(
|
|
161
|
+
fs.readFileSync(path.join(repoRoot, SUPERVISE_STATE_REL), 'utf-8'),
|
|
162
|
+
);
|
|
163
|
+
if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) return {};
|
|
164
|
+
const out: RelaunchLedger = {};
|
|
165
|
+
for (const [k, v] of Object.entries(parsed as Record<string, unknown>)) {
|
|
166
|
+
if (typeof v === 'number' && Number.isFinite(v) && v >= 0) out[k] = v;
|
|
167
|
+
}
|
|
168
|
+
return out;
|
|
169
|
+
} catch {
|
|
170
|
+
return {};
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Persist the relaunch ledger. THROWS on a failed write — never swallows.
|
|
176
|
+
*
|
|
177
|
+
* The swallow this replaces carried a comment asserting "there is NO CALLER …
|
|
178
|
+
* `writeLedger` is a writer waiting for one", and that was **false in the same
|
|
179
|
+
* file**: `digest` calls it at the `released > 0` branch below. The comment
|
|
180
|
+
* described the tree as it stood before R2 round 3 added the release, and the
|
|
181
|
+
* two comments then contradicted each other about the same function fourteen
|
|
182
|
+
* lines apart.
|
|
183
|
+
*
|
|
184
|
+
* The consequence was not cosmetic. A silently-failed write leaves the counter
|
|
185
|
+
* high while the digest has already printed `relaunch budget reset`, so the
|
|
186
|
+
* operator reads a release that did not happen — and the counter that "never
|
|
187
|
+
* releases" is precisely the failure R2 round 3 introduced `clearCompleted` to
|
|
188
|
+
* fix. Throwing hands the caller the choice; the caller below reports what
|
|
189
|
+
* actually happened rather than what it intended.
|
|
190
|
+
*/
|
|
191
|
+
export function writeLedger(repoRoot: string, ledger: RelaunchLedger): void {
|
|
192
|
+
const file = path.join(repoRoot, SUPERVISE_STATE_REL);
|
|
193
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
194
|
+
fs.writeFileSync(file, `${JSON.stringify(ledger, null, 2)}\n`, 'utf-8');
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* Classify one register record.
|
|
199
|
+
*
|
|
200
|
+
* Order matters and is the whole logic: a live session is never a candidate
|
|
201
|
+
* however far behind it is, and a finished roadmap is never a candidate
|
|
202
|
+
* however dead its session is. Only the intersection — dead session, real
|
|
203
|
+
* remaining work, budget left — is relaunchable.
|
|
204
|
+
*/
|
|
205
|
+
export function classify(
|
|
206
|
+
repoRoot: string,
|
|
207
|
+
rec: SessionRecord,
|
|
208
|
+
ledger: RelaunchLedger,
|
|
209
|
+
now: Date,
|
|
210
|
+
): Candidate {
|
|
211
|
+
const base = {
|
|
212
|
+
session_id: rec.session_id,
|
|
213
|
+
roadmap: rec.roadmap_slug,
|
|
214
|
+
worktree: rec.worktree,
|
|
215
|
+
platform: rec.platform,
|
|
216
|
+
relaunches: ledger[rec.roadmap_slug ?? ''] ?? 0,
|
|
217
|
+
};
|
|
218
|
+
if (!is_expired(rec, now)) {
|
|
219
|
+
return { ...base, disposition: 'alive', open_steps: null, reason: 'session is still beating' };
|
|
220
|
+
}
|
|
221
|
+
if (rec.roadmap_slug === null || rec.roadmap_slug === '') {
|
|
222
|
+
return {
|
|
223
|
+
...base,
|
|
224
|
+
disposition: 'no-roadmap',
|
|
225
|
+
open_steps: null,
|
|
226
|
+
reason: 'session died holding no roadmap claim — nothing to resume',
|
|
227
|
+
};
|
|
228
|
+
}
|
|
229
|
+
let text: string;
|
|
230
|
+
try {
|
|
231
|
+
text = fs.readFileSync(roadmapPath(repoRoot, rec.roadmap_slug), 'utf-8');
|
|
232
|
+
} catch {
|
|
233
|
+
return {
|
|
234
|
+
...base,
|
|
235
|
+
disposition: 'roadmap-unreadable',
|
|
236
|
+
open_steps: null,
|
|
237
|
+
reason: `claimed roadmap '${rec.roadmap_slug}' no longer reads — likely archived; the claim is stale, not the work`,
|
|
238
|
+
};
|
|
239
|
+
}
|
|
240
|
+
const counts = countRoadmap(text);
|
|
241
|
+
if (counts.open === 0) {
|
|
242
|
+
return {
|
|
243
|
+
...base,
|
|
244
|
+
disposition: 'complete',
|
|
245
|
+
open_steps: 0,
|
|
246
|
+
reason: 'no open steps remain — the run finished, the session just never said so',
|
|
247
|
+
};
|
|
248
|
+
}
|
|
249
|
+
if (base.relaunches >= MAX_RELAUNCHES_PER_RUN) {
|
|
250
|
+
return {
|
|
251
|
+
...base,
|
|
252
|
+
disposition: 'budget-exhausted',
|
|
253
|
+
open_steps: counts.open,
|
|
254
|
+
reason:
|
|
255
|
+
`${base.relaunches} relaunches already spent (cap ${MAX_RELAUNCHES_PER_RUN}) — ` +
|
|
256
|
+
`a run that dies this often has a problem a fourth session will not fix`,
|
|
257
|
+
};
|
|
258
|
+
}
|
|
259
|
+
return {
|
|
260
|
+
...base,
|
|
261
|
+
disposition: 'relaunchable',
|
|
262
|
+
open_steps: counts.open,
|
|
263
|
+
reason: `${counts.open} open step(s) remain and the session is gone`,
|
|
264
|
+
};
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
export interface ScanOptions {
|
|
268
|
+
readonly now?: Date;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/**
|
|
272
|
+
* Every record in the register, expired ones INCLUDED — and it has to be its
|
|
273
|
+
* own reader rather than `read_live_records`, because that one filters expired
|
|
274
|
+
* records out by design. Reading only live records here would return exactly
|
|
275
|
+
* the sessions that need no supervision and none of the ones that do.
|
|
276
|
+
*
|
|
277
|
+
* It also never prunes, and since R2 round 4 finding 5 it no longer has to
|
|
278
|
+
* hope the other readers do not. `read_live_records({ prune: true })` holds an
|
|
279
|
+
* expired record for `PRUNE_GRACE_MS` (24 h, derived from this digest's daily
|
|
280
|
+
* cadence) before unlinking it, so the two routine read paths that DO prune —
|
|
281
|
+
* `sessions:list` and the session-start hook, which fires on every start —
|
|
282
|
+
* can no longer delete this watcher's entire input between one morning and the
|
|
283
|
+
* next.
|
|
284
|
+
*
|
|
285
|
+
* `read_live_records({ prune: true })` deletes expired
|
|
286
|
+
* records as it walks; deleting the evidence a watcher exists to act on would
|
|
287
|
+
* make the first scan the last one that could see anything.
|
|
288
|
+
*/
|
|
289
|
+
export function readAllRecords(dir: string): SessionRecord[] {
|
|
290
|
+
let names: string[];
|
|
291
|
+
try {
|
|
292
|
+
names = fs.readdirSync(dir).filter((n) => n.endsWith('.json'));
|
|
293
|
+
} catch {
|
|
294
|
+
return [];
|
|
295
|
+
}
|
|
296
|
+
const out: SessionRecord[] = [];
|
|
297
|
+
for (const name of names) {
|
|
298
|
+
try {
|
|
299
|
+
const parsed: unknown = JSON.parse(fs.readFileSync(path.join(dir, name), 'utf-8'));
|
|
300
|
+
if (
|
|
301
|
+
parsed !== null &&
|
|
302
|
+
typeof parsed === 'object' &&
|
|
303
|
+
!Array.isArray(parsed) &&
|
|
304
|
+
typeof (parsed as Record<string, unknown>)['session_id'] === 'string' &&
|
|
305
|
+
typeof (parsed as Record<string, unknown>)['last_seen'] === 'string'
|
|
306
|
+
) {
|
|
307
|
+
out.push(parsed as SessionRecord);
|
|
308
|
+
}
|
|
309
|
+
} catch {
|
|
310
|
+
// An unparseable file carries no evidence of a run; skipping it is
|
|
311
|
+
// the same call `read_live_records` makes, minus the deletion.
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
return out.sort((a, b) => a.session_id.localeCompare(b.session_id));
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
export function scan(repoRoot: string, opts: ScanOptions = {}): Candidate[] {
|
|
318
|
+
const dir = register_dir(repoRoot);
|
|
319
|
+
if (dir === null) return [];
|
|
320
|
+
const now = opts.now ?? new Date();
|
|
321
|
+
const ledger = readLedger(repoRoot);
|
|
322
|
+
return readAllRecords(dir).map((rec) => classify(repoRoot, rec, ledger, now));
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
/**
|
|
326
|
+
* Build one resume plan per relaunchable candidate.
|
|
327
|
+
*
|
|
328
|
+
* Only `relaunchable` ones: an alive session needs no resume, a complete
|
|
329
|
+
* roadmap has nothing left, and a cap-exhausted run is refused before a
|
|
330
|
+
* command is worth printing. Printing a command for those would hand the
|
|
331
|
+
* operator a line that undoes the classification the scan just made.
|
|
332
|
+
*/
|
|
333
|
+
export function resumePlans(
|
|
334
|
+
repoRoot: string,
|
|
335
|
+
candidates: readonly Candidate[],
|
|
336
|
+
now: Date,
|
|
337
|
+
): ResumePlan[] {
|
|
338
|
+
const out: ResumePlan[] = [];
|
|
339
|
+
for (const c of candidates) {
|
|
340
|
+
if (c.disposition !== 'relaunchable' || c.roadmap === null) continue;
|
|
341
|
+
out.push(
|
|
342
|
+
planResume(
|
|
343
|
+
repoRoot,
|
|
344
|
+
{
|
|
345
|
+
roadmapSlug: c.roadmap,
|
|
346
|
+
worktree: c.worktree,
|
|
347
|
+
platform: c.platform,
|
|
348
|
+
// A worktree whose head cannot be read yields the literal
|
|
349
|
+
// `unknown`, which then rides into the dedup key. That is
|
|
350
|
+
// deliberate: an unreadable head must not collide with a
|
|
351
|
+
// real commit's key, and it is visible in the output rather
|
|
352
|
+
// than silently absent.
|
|
353
|
+
head: readHead(c.worktree) ?? 'unknown',
|
|
354
|
+
},
|
|
355
|
+
now,
|
|
356
|
+
),
|
|
357
|
+
);
|
|
358
|
+
}
|
|
359
|
+
return out;
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
export function render(candidates: readonly Candidate[]): string {
|
|
363
|
+
if (candidates.length === 0) {
|
|
364
|
+
return 'run:supervise — no sessions registered for this workspace.\n';
|
|
365
|
+
}
|
|
366
|
+
const lines: string[] = [];
|
|
367
|
+
for (const c of candidates) {
|
|
368
|
+
const tag = c.disposition.toUpperCase().padEnd(18);
|
|
369
|
+
const road = c.roadmap ?? '-';
|
|
370
|
+
lines.push(` ${tag} ${c.session_id} roadmap=${road} ${c.reason}`);
|
|
371
|
+
}
|
|
372
|
+
const n = candidates.filter((c) => c.disposition === 'relaunchable').length;
|
|
373
|
+
lines.push('');
|
|
374
|
+
lines.push(
|
|
375
|
+
n === 0
|
|
376
|
+
? 'run:supervise — nothing to relaunch.'
|
|
377
|
+
: `run:supervise — ${n} run(s) relaunchable. This process NEVER merges, pushes, or ` +
|
|
378
|
+
`closes anything; --relaunch only starts a fresh session.`,
|
|
379
|
+
);
|
|
380
|
+
return `${lines.join('\n')}\n`;
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
/**
|
|
384
|
+
* The morning digest (UOTL 7.2) — what happened while nobody was watching.
|
|
385
|
+
*
|
|
386
|
+
* The point of the phrasing in UOTL is "instead of permission prompts": an
|
|
387
|
+
* unattended lane that interrupts is not unattended, so the reporting has to
|
|
388
|
+
* be something a human reads once, on their own schedule.
|
|
389
|
+
*
|
|
390
|
+
* It reports only state that ALREADY EXISTS on disk — dead runs, memos
|
|
391
|
+
* written, budget consumed. No scheduling, no cron entry, no spawn. A digest
|
|
392
|
+
* over an empty tree is the honest output of a lane that has not run yet, and
|
|
393
|
+
* is much better than a scheduler that schedules something nothing can
|
|
394
|
+
* execute.
|
|
395
|
+
*/
|
|
396
|
+
export function digest(repoRoot: string, candidates: readonly Candidate[], now: Date): string {
|
|
397
|
+
const lines = [`run:supervise digest · ${now.toISOString().slice(0, 10)}`, ''];
|
|
398
|
+
|
|
399
|
+
// The one write this read-report performs, and it is stated here rather
|
|
400
|
+
// than buried: a completed roadmap's relaunch counter is released, so the
|
|
401
|
+
// "three per run" cap bounds a run instead of a roadmap's lifetime
|
|
402
|
+
// (R2 round 3, finding 6).
|
|
403
|
+
//
|
|
404
|
+
// The report follows the WRITE and the DELTA, not the intent. It used to
|
|
405
|
+
// push "relaunch budget reset" unconditionally after a `writeLedger` that
|
|
406
|
+
// swallowed its own failure, so a digest could tell the operator a
|
|
407
|
+
// release had happened while the counter still stood — the direction that
|
|
408
|
+
// matters, because a stale-high counter refuses the next relaunch and the
|
|
409
|
+
// digest had just said it would not. Round 1 fixed the write half and
|
|
410
|
+
// left the count reading off the candidate set; both halves are needed,
|
|
411
|
+
// and a read-report that writes when nothing changed is the smaller half
|
|
412
|
+
// of the same mistake.
|
|
413
|
+
const before = readLedger(repoRoot);
|
|
414
|
+
const cleared = clearCompleted(before, candidates);
|
|
415
|
+
// The count is the LEDGER DELTA, never the candidate set.
|
|
416
|
+
//
|
|
417
|
+
// R2 round 2, finding 7, and it is the same defect one level up from the
|
|
418
|
+
// one round 1 fixed. Counting completed candidates reports a release for a
|
|
419
|
+
// roadmap that never had a counter — and with no relaunch mechanism
|
|
420
|
+
// shipping, the ledger is always empty, so EVERY such line was false. It
|
|
421
|
+
// also made a read-report write `{}` to disk on its first run.
|
|
422
|
+
const released = Object.keys(before).length - Object.keys(cleared).length;
|
|
423
|
+
if (released > 0) {
|
|
424
|
+
try {
|
|
425
|
+
writeLedger(repoRoot, cleared);
|
|
426
|
+
lines.push(` released: ${released} completed roadmap(s) — relaunch budget reset`);
|
|
427
|
+
} catch (err) {
|
|
428
|
+
lines.push(
|
|
429
|
+
` released: NOT RESET — ${released} completed roadmap(s) still hold their ` +
|
|
430
|
+
`counter (ledger write failed: ${err instanceof Error ? err.message : String(err)})`,
|
|
431
|
+
);
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
const dead = candidates.filter((c) => c.disposition !== 'alive');
|
|
436
|
+
const relaunchable = candidates.filter((c) => c.disposition === 'relaunchable');
|
|
437
|
+
lines.push(
|
|
438
|
+
` sessions: ${candidates.length} registered · ${candidates.length - dead.length} alive · ` +
|
|
439
|
+
`${relaunchable.length} relaunchable`,
|
|
440
|
+
);
|
|
441
|
+
|
|
442
|
+
const budget = readBudget(repoRoot, now);
|
|
443
|
+
lines.push(
|
|
444
|
+
budget.max_usd <= 0 && budget.max_tokens <= 0
|
|
445
|
+
? ' budget: unattended runs DISABLED (no ceiling configured)'
|
|
446
|
+
: ` budget: $${budget.spent_usd}/${budget.max_usd} · ` +
|
|
447
|
+
`${budget.spent_tokens}/${budget.max_tokens} tokens · window ${budget.window_opened}`,
|
|
448
|
+
);
|
|
449
|
+
|
|
450
|
+
const memoRoot = path.join(repoRoot, 'agents', 'runtime', 'state', 'decisions');
|
|
451
|
+
let memoCount = 0;
|
|
452
|
+
let memoRuns = 0;
|
|
453
|
+
let entries: string[] = [];
|
|
454
|
+
try {
|
|
455
|
+
entries = fs.readdirSync(memoRoot);
|
|
456
|
+
} catch {
|
|
457
|
+
// no memos yet — reported as zero below, which is a real answer
|
|
458
|
+
}
|
|
459
|
+
for (const run of entries) {
|
|
460
|
+
// The try is INSIDE the loop. R2 review, finding 6: with it outside,
|
|
461
|
+
// one non-directory entry under decisions/ — a .DS_Store, a stray
|
|
462
|
+
// note — threw ENOTDIR, aborted the whole walk and reported whatever
|
|
463
|
+
// partial count had been reached, which is 0 when the bad entry sorts
|
|
464
|
+
// first. `interruption_report.readMemoCounts` walks the same tree with
|
|
465
|
+
// the try inside, so the two disagreed on identical data.
|
|
466
|
+
try {
|
|
467
|
+
const files = fs
|
|
468
|
+
.readdirSync(path.join(memoRoot, run))
|
|
469
|
+
.filter((n) => /^\d{3}\.md$/.test(n));
|
|
470
|
+
if (files.length > 0) {
|
|
471
|
+
memoRuns += 1;
|
|
472
|
+
memoCount += files.length;
|
|
473
|
+
}
|
|
474
|
+
} catch {
|
|
475
|
+
// a file where a directory was expected — not a run
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
lines.push(` decisions: ${memoCount} memo(s) across ${memoRuns} run(s)`);
|
|
479
|
+
|
|
480
|
+
if (relaunchable.length > 0) {
|
|
481
|
+
lines.push('');
|
|
482
|
+
lines.push(' needs attention:');
|
|
483
|
+
for (const c of relaunchable) {
|
|
484
|
+
lines.push(` ${c.session_id} roadmap=${c.roadmap ?? '-'} ${c.reason}`);
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
lines.push('');
|
|
488
|
+
lines.push(' This digest reports state; it schedules nothing and starts nothing.');
|
|
489
|
+
return `${lines.join('\n')}\n`;
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
const USAGE = `usage: run_supervise [--root PATH] [--once] [--digest] [--interval SECONDS]
|
|
493
|
+
[--print-relaunch] [--relaunch]
|
|
494
|
+
|
|
495
|
+
Watches the session register for runs whose session died with open steps left.
|
|
496
|
+
|
|
497
|
+
--root PATH repository root (default: cwd)
|
|
498
|
+
--once one scan, then exit. REQUIRED today: looping is not
|
|
499
|
+
implemented, so omitting this exits 2 with that message.
|
|
500
|
+
--digest the morning report instead of the per-session list:
|
|
501
|
+
dead runs, decision memos written, budget consumed.
|
|
502
|
+
Reports state; schedules nothing, starts nothing.
|
|
503
|
+
--interval SECONDS accepted and IGNORED. It used to say "reserved for the
|
|
504
|
+
loop driver that lands with the Phase 4.0 primitive";
|
|
505
|
+
that primitive is a published refusal now, so nothing is
|
|
506
|
+
scheduled to land and the flag has no future caller.
|
|
507
|
+
Listed rather than hidden so it is not silently dropped,
|
|
508
|
+
and marked rather than documented with a default (an
|
|
509
|
+
earlier round: it advertised "default: 60" and was read
|
|
510
|
+
by nothing, so an operator following this help got exit 2
|
|
511
|
+
and no watcher).
|
|
512
|
+
--print-relaunch PRINT the exact command that resumes each relaunchable
|
|
513
|
+
run, plus what an unattended lane would decide about the
|
|
514
|
+
same run. Starts no SESSION and spends nothing; it does
|
|
515
|
+
run one \`git remote -v\` per plan, which is the guard's
|
|
516
|
+
production-remote precondition. The command is for a
|
|
517
|
+
human to paste; the unattended verdict never gates what
|
|
518
|
+
a human may run.
|
|
519
|
+
--relaunch REFUSED. Starting a session unattended is a published
|
|
520
|
+
refusal (road-to-long-horizon-execution 4.0, AI council
|
|
521
|
+
2026-08-19), not an unbuilt feature — the flag exits 2
|
|
522
|
+
naming the decision and its reopen condition. The
|
|
523
|
+
${MAX_RELAUNCHES_PER_RUN}-per-run cap and its ledger stay
|
|
524
|
+
in place for the day the refusal is reopened.
|
|
525
|
+
|
|
526
|
+
Never merges, never pushes, never closes a PR. Set
|
|
527
|
+
${HALT_ENV}=1 to stop the watcher.
|
|
528
|
+
`;
|
|
529
|
+
|
|
530
|
+
function argValue(argv: string[], flag: string): string | null {
|
|
531
|
+
const i = argv.indexOf(flag);
|
|
532
|
+
if (i === -1 || i + 1 >= argv.length) return null;
|
|
533
|
+
return argv[i + 1] ?? null;
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
export function main(argv: string[] = process.argv.slice(2)): number {
|
|
537
|
+
if (argv.includes('--help') || argv.includes('-h')) {
|
|
538
|
+
process.stdout.write(USAGE);
|
|
539
|
+
return 0;
|
|
540
|
+
}
|
|
541
|
+
if (String(process.env[HALT_ENV] ?? '').trim() !== '') {
|
|
542
|
+
process.stdout.write(`run:supervise — ${HALT_ENV} is set; watcher does not start.\n`);
|
|
543
|
+
return 0;
|
|
544
|
+
}
|
|
545
|
+
// Report-shape flags are mutually exclusive, and a silent precedence is
|
|
546
|
+
// the wrong way to say so (R2 round 2, finding 10): `--digest
|
|
547
|
+
// --print-relaunch` used to print the digest and drop the plans without a
|
|
548
|
+
// word, and `--print-relaunch --relaunch` returned 0 without ever emitting
|
|
549
|
+
// the refusal — success as the exit code of the one flag whose entire
|
|
550
|
+
// purpose is to refuse.
|
|
551
|
+
const shapes = ['--digest', '--print-relaunch', '--relaunch'].filter((f) => argv.includes(f));
|
|
552
|
+
if (shapes.length > 1) {
|
|
553
|
+
process.stderr.write(
|
|
554
|
+
`run:supervise: ${shapes.join(' and ')} are mutually exclusive — each is a ` +
|
|
555
|
+
`different report, and one silently winning would hide the other. Pick one.\n`,
|
|
556
|
+
);
|
|
557
|
+
return 2;
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
const repoRoot = argValue(argv, '--root') ?? process.cwd();
|
|
561
|
+
const now = new Date();
|
|
562
|
+
const candidates = scan(repoRoot, { now });
|
|
563
|
+
if (argv.includes('--digest')) {
|
|
564
|
+
process.stdout.write(digest(repoRoot, candidates, now));
|
|
565
|
+
return 0;
|
|
566
|
+
}
|
|
567
|
+
process.stdout.write(render(candidates));
|
|
568
|
+
|
|
569
|
+
if (argv.includes('--print-relaunch')) {
|
|
570
|
+
process.stdout.write(renderResumePlans(resumePlans(repoRoot, candidates, now)));
|
|
571
|
+
return 0;
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
if (argv.includes('--relaunch')) {
|
|
575
|
+
// A REFUSAL, not a pending implementation. The previous wording —
|
|
576
|
+
// "accepted but NOT implemented … is Phase 4.0 … and is not built" —
|
|
577
|
+
// is the sentence that makes an operator wait for a release nobody is
|
|
578
|
+
// preparing, which is D-5's indefinite-pending shape reproduced inside
|
|
579
|
+
// the roadmap that names it. The decision and its reopen condition are
|
|
580
|
+
// in `LIVE_SPAWN_REFUSAL`.
|
|
581
|
+
process.stderr.write(`${LIVE_SPAWN_REFUSAL}\n`);
|
|
582
|
+
return 2;
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
if (!argv.includes('--once')) {
|
|
586
|
+
process.stderr.write(
|
|
587
|
+
'run:supervise: looping is not implemented — use --once. The scan above is the ' +
|
|
588
|
+
'full surface (Phase 3.1: a foreground loop is enough to falsify the design). ' +
|
|
589
|
+
'The loop driver was tied to the Phase 4.0 spawn, which is a published refusal, ' +
|
|
590
|
+
'so it is not pending — there is nothing to wait for.\n',
|
|
591
|
+
);
|
|
592
|
+
return 2;
|
|
593
|
+
}
|
|
594
|
+
return 0;
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
function _isCliEntry(): boolean {
|
|
598
|
+
if (process.argv[1] === undefined) return false;
|
|
599
|
+
const argvUrl = pathToFileURL(path.resolve(process.argv[1])).href;
|
|
600
|
+
if (import.meta.url === argvUrl) return true;
|
|
601
|
+
try {
|
|
602
|
+
return (
|
|
603
|
+
fs.realpathSync(fileURLToPath(import.meta.url)) ===
|
|
604
|
+
fs.realpathSync(path.resolve(process.argv[1]))
|
|
605
|
+
);
|
|
606
|
+
} catch {
|
|
607
|
+
return false;
|
|
608
|
+
}
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
if (_isCliEntry()) {
|
|
612
|
+
process.exit(main());
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
/**
|
|
616
|
+
* Re-exported so a resumed run reaches its checkpoint through one path.
|
|
617
|
+
*
|
|
618
|
+
* `latestCheckpointFor` is the one a RELAUNCHED session can actually use: it
|
|
619
|
+
* keys on the roadmap slug, which such a session holds by definition, whereas
|
|
620
|
+
* `readCheckpoint` keys on the run id derived from the session that died.
|
|
621
|
+
* R2 review, finding 7 — the checkpoint was write-only until this existed.
|
|
622
|
+
*/
|
|
623
|
+
export { latestCheckpointFor, readCheckpoint };
|