@delegance/claude-autopilot 5.5.2 → 6.2.2

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 (119) hide show
  1. package/CHANGELOG.md +935 -6
  2. package/README.md +55 -0
  3. package/dist/src/adapters/council/openai.js +12 -6
  4. package/dist/src/adapters/deploy/_http.d.ts +43 -0
  5. package/dist/src/adapters/deploy/_http.js +99 -0
  6. package/dist/src/adapters/deploy/fly.d.ts +206 -0
  7. package/dist/src/adapters/deploy/fly.js +696 -0
  8. package/dist/src/adapters/deploy/index.d.ts +2 -0
  9. package/dist/src/adapters/deploy/index.js +33 -0
  10. package/dist/src/adapters/deploy/render.d.ts +181 -0
  11. package/dist/src/adapters/deploy/render.js +550 -0
  12. package/dist/src/adapters/deploy/types.d.ts +67 -3
  13. package/dist/src/adapters/deploy/vercel.d.ts +17 -1
  14. package/dist/src/adapters/deploy/vercel.js +29 -49
  15. package/dist/src/adapters/pricing.d.ts +36 -0
  16. package/dist/src/adapters/pricing.js +40 -0
  17. package/dist/src/adapters/review-engine/codex.js +10 -7
  18. package/dist/src/cli/autopilot.d.ts +71 -0
  19. package/dist/src/cli/autopilot.js +735 -0
  20. package/dist/src/cli/brainstorm.d.ts +23 -0
  21. package/dist/src/cli/brainstorm.js +131 -0
  22. package/dist/src/cli/costs.d.ts +15 -1
  23. package/dist/src/cli/costs.js +99 -10
  24. package/dist/src/cli/deploy.d.ts +3 -3
  25. package/dist/src/cli/deploy.js +34 -9
  26. package/dist/src/cli/fix.d.ts +18 -0
  27. package/dist/src/cli/fix.js +105 -11
  28. package/dist/src/cli/help-text.d.ts +52 -0
  29. package/dist/src/cli/help-text.js +400 -0
  30. package/dist/src/cli/implement.d.ts +91 -0
  31. package/dist/src/cli/implement.js +196 -0
  32. package/dist/src/cli/index.js +719 -245
  33. package/dist/src/cli/json-envelope.d.ts +187 -0
  34. package/dist/src/cli/json-envelope.js +270 -0
  35. package/dist/src/cli/json-mode.d.ts +33 -0
  36. package/dist/src/cli/json-mode.js +201 -0
  37. package/dist/src/cli/migrate.d.ts +111 -0
  38. package/dist/src/cli/migrate.js +305 -0
  39. package/dist/src/cli/plan.d.ts +81 -0
  40. package/dist/src/cli/plan.js +149 -0
  41. package/dist/src/cli/pr.d.ts +106 -0
  42. package/dist/src/cli/pr.js +191 -19
  43. package/dist/src/cli/preflight.js +26 -0
  44. package/dist/src/cli/review.d.ts +27 -0
  45. package/dist/src/cli/review.js +126 -0
  46. package/dist/src/cli/runs-watch-renderer.d.ts +45 -0
  47. package/dist/src/cli/runs-watch-renderer.js +275 -0
  48. package/dist/src/cli/runs-watch.d.ts +41 -0
  49. package/dist/src/cli/runs-watch.js +395 -0
  50. package/dist/src/cli/runs.d.ts +122 -0
  51. package/dist/src/cli/runs.js +902 -0
  52. package/dist/src/cli/scan.d.ts +93 -0
  53. package/dist/src/cli/scan.js +166 -40
  54. package/dist/src/cli/spec.d.ts +66 -0
  55. package/dist/src/cli/spec.js +132 -0
  56. package/dist/src/cli/validate.d.ts +29 -0
  57. package/dist/src/cli/validate.js +131 -0
  58. package/dist/src/core/config/schema.d.ts +9 -0
  59. package/dist/src/core/config/schema.js +7 -0
  60. package/dist/src/core/config/types.d.ts +11 -0
  61. package/dist/src/core/council/runner.d.ts +10 -1
  62. package/dist/src/core/council/runner.js +25 -3
  63. package/dist/src/core/council/types.d.ts +7 -0
  64. package/dist/src/core/errors.d.ts +1 -1
  65. package/dist/src/core/errors.js +11 -0
  66. package/dist/src/core/logging/redaction.d.ts +13 -0
  67. package/dist/src/core/logging/redaction.js +20 -0
  68. package/dist/src/core/migrate/schema-validator.js +15 -1
  69. package/dist/src/core/phases/static-rules.d.ts +5 -1
  70. package/dist/src/core/phases/static-rules.js +2 -5
  71. package/dist/src/core/run-state/budget.d.ts +88 -0
  72. package/dist/src/core/run-state/budget.js +141 -0
  73. package/dist/src/core/run-state/cli-internal.d.ts +21 -0
  74. package/dist/src/core/run-state/cli-internal.js +174 -0
  75. package/dist/src/core/run-state/events.d.ts +59 -0
  76. package/dist/src/core/run-state/events.js +504 -0
  77. package/dist/src/core/run-state/lock.d.ts +61 -0
  78. package/dist/src/core/run-state/lock.js +206 -0
  79. package/dist/src/core/run-state/phase-context.d.ts +60 -0
  80. package/dist/src/core/run-state/phase-context.js +108 -0
  81. package/dist/src/core/run-state/phase-registry.d.ts +137 -0
  82. package/dist/src/core/run-state/phase-registry.js +162 -0
  83. package/dist/src/core/run-state/phase-runner.d.ts +80 -0
  84. package/dist/src/core/run-state/phase-runner.js +447 -0
  85. package/dist/src/core/run-state/provider-readback.d.ts +130 -0
  86. package/dist/src/core/run-state/provider-readback.js +426 -0
  87. package/dist/src/core/run-state/replay-decision.d.ts +69 -0
  88. package/dist/src/core/run-state/replay-decision.js +144 -0
  89. package/dist/src/core/run-state/resolve-engine.d.ts +100 -0
  90. package/dist/src/core/run-state/resolve-engine.js +190 -0
  91. package/dist/src/core/run-state/resume-preflight.d.ts +66 -0
  92. package/dist/src/core/run-state/resume-preflight.js +116 -0
  93. package/dist/src/core/run-state/run-phase-with-lifecycle.d.ts +73 -0
  94. package/dist/src/core/run-state/run-phase-with-lifecycle.js +186 -0
  95. package/dist/src/core/run-state/runs.d.ts +57 -0
  96. package/dist/src/core/run-state/runs.js +288 -0
  97. package/dist/src/core/run-state/snapshot.d.ts +14 -0
  98. package/dist/src/core/run-state/snapshot.js +114 -0
  99. package/dist/src/core/run-state/state.d.ts +40 -0
  100. package/dist/src/core/run-state/state.js +164 -0
  101. package/dist/src/core/run-state/types.d.ts +278 -0
  102. package/dist/src/core/run-state/types.js +13 -0
  103. package/dist/src/core/run-state/ulid.d.ts +11 -0
  104. package/dist/src/core/run-state/ulid.js +95 -0
  105. package/dist/src/core/schema-alignment/extractor/index.d.ts +1 -1
  106. package/dist/src/core/schema-alignment/extractor/index.js +2 -2
  107. package/dist/src/core/schema-alignment/extractor/prisma.d.ts +13 -1
  108. package/dist/src/core/schema-alignment/extractor/prisma.js +65 -10
  109. package/dist/src/core/schema-alignment/git-history.d.ts +19 -0
  110. package/dist/src/core/schema-alignment/git-history.js +53 -0
  111. package/dist/src/core/static-rules/rules/brand-tokens.js +2 -2
  112. package/dist/src/core/static-rules/rules/schema-alignment.js +14 -4
  113. package/package.json +2 -1
  114. package/scripts/autoregress.ts +1 -1
  115. package/skills/claude-autopilot.md +1 -1
  116. package/skills/make-interfaces-feel-better/SKILL.md +104 -0
  117. package/skills/simplify-ui/SKILL.md +103 -0
  118. package/skills/ui/SKILL.md +117 -0
  119. package/skills/ui-ux-pro-max/SKILL.md +90 -0
@@ -0,0 +1,902 @@
1
+ // src/cli/runs.ts
2
+ //
3
+ // v6 Phase 3 — user-facing CLI surface over the Phase 1 (persistence) and
4
+ // Phase 2 (phase wrapper) APIs. Six verbs, all read-only or scoped-delete:
5
+ //
6
+ // runs list — wrap listRuns; --status filter; newest-first
7
+ // runs show <id> — render state.json + tail of events.ndjson
8
+ // runs gc — wrap gcRuns; default 30d cutoff; confirmation
9
+ // runs delete <id> — explicit single-run delete, terminal-status only
10
+ // run resume <id> — LOOKUP-ONLY: identify nextPhase + decision
11
+ // runs doctor — replay events vs. state.json; report drift; --fix
12
+ //
13
+ // Phase 3 is read/inspect + GC. Actual phase execution on resume lands in
14
+ // Phase 6+; here `run resume` just answers "what would happen if I resumed?".
15
+ // This is documented in the function body and in `runs resume --help` text.
16
+ //
17
+ // Spec: docs/specs/v6-run-state-engine.md "Resume command", "CLI `--json`
18
+ // mode", "Migration path". `--json` envelope shape in Phase 3 is the v1
19
+ // surface; strict stdout/stderr channel discipline lands in Phase 5.
20
+ import * as fs from 'node:fs';
21
+ import * as path from 'node:path';
22
+ import * as readline from 'node:readline';
23
+ import { GuardrailError } from "../core/errors.js";
24
+ import { foldEvents, readEvents } from "../core/run-state/events.js";
25
+ import { acquireRunLock } from "../core/run-state/lock.js";
26
+ import { decideReplay } from "../core/run-state/replay-decision.js";
27
+ import { readStateSnapshot, statePath, writeStateSnapshot } from "../core/run-state/state.js";
28
+ import { isValidULID } from "../core/run-state/ulid.js";
29
+ import { gcRuns, listRuns, rebuildIndex, runDirFor, runsRoot, } from "../core/run-state/runs.js";
30
+ // ----------------------------------------------------------------------------
31
+ // Shared envelope shape for --json output. Phase 3 keeps the surface minimal;
32
+ // strict stdout/stderr discipline + per-command schema validation lands in
33
+ // Phase 5 (see spec "CLI --json mode + strict channel discipline").
34
+ // ----------------------------------------------------------------------------
35
+ const ENVELOPE_SCHEMA_VERSION = 1;
36
+ /** Validate that `runId` is a ULID. Throws GuardrailError(invalid_config) if
37
+ * not — keeps the surface uniform across verbs that take a runId. */
38
+ function assertValidRunId(runId) {
39
+ if (!runId) {
40
+ throw new GuardrailError('a run id is required', {
41
+ code: 'invalid_config',
42
+ provider: 'runs-cli',
43
+ details: { runId },
44
+ });
45
+ }
46
+ if (!isValidULID(runId)) {
47
+ throw new GuardrailError(`run id is not a valid ULID: ${runId}`, {
48
+ code: 'invalid_config',
49
+ provider: 'runs-cli',
50
+ details: { runId },
51
+ });
52
+ }
53
+ }
54
+ /** Format a GuardrailError into a one-line `[code] message` string. */
55
+ function formatErr(err) {
56
+ if (err instanceof GuardrailError)
57
+ return `${err.code}: ${err.message}`;
58
+ if (err instanceof Error)
59
+ return err.message;
60
+ return String(err);
61
+ }
62
+ /** Convert a result + json-mode flag into the final envelope when --json is
63
+ * set. The envelope is emitted as a single line on stdout; stderr lines from
64
+ * the result are dropped under --json (Phase 3 limitation; Phase 5 will route
65
+ * them as NDJSON events). */
66
+ function maybeEnvelope(command, json, result, payload) {
67
+ if (!json)
68
+ return result;
69
+ const envelope = {
70
+ schema_version: ENVELOPE_SCHEMA_VERSION,
71
+ command,
72
+ status: result.exit === 0 ? 'pass' : 'fail',
73
+ exit: result.exit,
74
+ ...payload,
75
+ };
76
+ return { exit: result.exit, stdout: [JSON.stringify(envelope)], stderr: [] };
77
+ }
78
+ const VALID_STATUS_FILTERS = new Set([
79
+ 'pending',
80
+ 'running',
81
+ 'paused',
82
+ 'success',
83
+ 'failed',
84
+ 'aborted',
85
+ ]);
86
+ /** `runs list` — newest-first listing. Optional --status filter narrows to
87
+ * one RunStatus. `--json` emits an envelope; text mode prints a tight
88
+ * table. */
89
+ export async function runRunsList(opts) {
90
+ const cwd = opts.cwd ?? process.cwd();
91
+ const json = !!opts.json;
92
+ // The status filter shares the spec's RunStatus shape but the CLI accepts
93
+ // a couple of common shorthands. We map "running" to 'running' (literal),
94
+ // "completed" to 'success' (the spec's terminal-success status), and
95
+ // "failed" to 'failed'. Anything else is rejected before we list.
96
+ let statusFilter;
97
+ if (opts.status) {
98
+ const s = opts.status.toLowerCase();
99
+ if (s === 'completed' || s === 'complete')
100
+ statusFilter = 'success';
101
+ else if (VALID_STATUS_FILTERS.has(s))
102
+ statusFilter = s;
103
+ else {
104
+ const err = new GuardrailError(`--status must be one of: pending, running, paused, completed, failed, aborted (got "${opts.status}")`, { code: 'invalid_config', provider: 'runs-cli', details: { status: opts.status } });
105
+ const result = {
106
+ exit: 1,
107
+ stdout: [],
108
+ stderr: [`[claude-autopilot] runs list: ${formatErr(err)}`],
109
+ };
110
+ return maybeEnvelope('runs list', json, result, {
111
+ error: formatErr(err),
112
+ });
113
+ }
114
+ }
115
+ let entries;
116
+ try {
117
+ entries = listRuns(cwd, { rebuild: true });
118
+ }
119
+ catch (err) {
120
+ const result = {
121
+ exit: 1,
122
+ stdout: [],
123
+ stderr: [`[claude-autopilot] runs list: ${formatErr(err)}`],
124
+ };
125
+ return maybeEnvelope('runs list', json, result, { error: formatErr(err) });
126
+ }
127
+ if (statusFilter) {
128
+ entries = entries.filter(e => e.status === statusFilter);
129
+ }
130
+ if (json) {
131
+ return maybeEnvelope('runs list', true, { exit: 0, stdout: [], stderr: [] }, { runs: entries, count: entries.length, ...(statusFilter ? { statusFilter } : {}) });
132
+ }
133
+ if (entries.length === 0) {
134
+ return {
135
+ exit: 0,
136
+ stdout: ['No runs.' + (statusFilter ? ` (filtered to status="${statusFilter}")` : '')],
137
+ stderr: [],
138
+ };
139
+ }
140
+ // Tight text table. Columns: runId | status | startedAt | cost | lastPhase
141
+ const lines = [];
142
+ lines.push(formatRunRow('runId', 'status', 'started', 'cost', 'lastPhase'));
143
+ lines.push(formatRunRow('-----', '------', '-------', '----', '---------'));
144
+ for (const e of entries) {
145
+ lines.push(formatRunRow(e.runId, e.status + (e.recovered ? '*' : ''), e.startedAt, `$${e.totalCostUSD.toFixed(2)}`, e.lastPhase ?? '-'));
146
+ }
147
+ if (entries.some(e => e.recovered)) {
148
+ lines.push('');
149
+ lines.push('* state recovered from events.ndjson — run `claude-autopilot runs doctor` to inspect');
150
+ }
151
+ return { exit: 0, stdout: lines, stderr: [] };
152
+ }
153
+ const COL_RUNID = 28;
154
+ const COL_STATUS = 11;
155
+ const COL_STARTED = 26;
156
+ const COL_COST = 9;
157
+ function pad(s, n) {
158
+ if (s.length >= n)
159
+ return s + ' ';
160
+ return s + ' '.repeat(n - s.length);
161
+ }
162
+ function formatRunRow(runId, status, startedAt, cost, lastPhase) {
163
+ return (pad(runId, COL_RUNID) +
164
+ pad(status, COL_STATUS) +
165
+ pad(startedAt, COL_STARTED) +
166
+ pad(cost, COL_COST) +
167
+ lastPhase);
168
+ }
169
+ /** `runs show <id>` — render state.json (or replay if missing) plus, with
170
+ * --events, the tail of events.ndjson. JSON mode bundles state + events into
171
+ * the envelope. */
172
+ export async function runRunsShow(opts) {
173
+ const cwd = opts.cwd ?? process.cwd();
174
+ const json = !!opts.json;
175
+ const tail = opts.eventsTail ?? 20;
176
+ try {
177
+ assertValidRunId(opts.runId);
178
+ }
179
+ catch (err) {
180
+ const result = {
181
+ exit: 1,
182
+ stdout: [],
183
+ stderr: [`[claude-autopilot] runs show: ${formatErr(err)}`],
184
+ };
185
+ return maybeEnvelope('runs show', json, result, { error: formatErr(err) });
186
+ }
187
+ const runDir = runDirFor(cwd, opts.runId);
188
+ if (!fs.existsSync(runDir)) {
189
+ const err = new GuardrailError(`run not found: ${opts.runId}`, {
190
+ code: 'not_found',
191
+ provider: 'runs-cli',
192
+ details: { runId: opts.runId, runDir },
193
+ });
194
+ const result = {
195
+ exit: 1,
196
+ stdout: [],
197
+ stderr: [`[claude-autopilot] runs show: ${formatErr(err)}`],
198
+ };
199
+ return maybeEnvelope('runs show', json, result, { error: formatErr(err), runId: opts.runId });
200
+ }
201
+ // Read state.json — if missing/corrupt, fall back to in-memory replay so
202
+ // we never modify the run during a read-only show.
203
+ let state = null;
204
+ let recovered = false;
205
+ try {
206
+ state = readStateSnapshot(runDir);
207
+ }
208
+ catch {
209
+ recovered = true;
210
+ }
211
+ if (!state) {
212
+ try {
213
+ const { events: replayEvents } = readEvents(runDir);
214
+ state = foldEvents(runDir, replayEvents);
215
+ recovered = true;
216
+ }
217
+ catch (err) {
218
+ const result = {
219
+ exit: 1,
220
+ stdout: [],
221
+ stderr: [`[claude-autopilot] runs show: ${formatErr(err)}`],
222
+ };
223
+ return maybeEnvelope('runs show', json, result, {
224
+ error: formatErr(err),
225
+ runId: opts.runId,
226
+ });
227
+ }
228
+ }
229
+ // Optional event tail (always read so JSON mode can include it; text mode
230
+ // prints only when --events is set).
231
+ let tailEvents = [];
232
+ if (opts.events || json) {
233
+ try {
234
+ tailEvents = readEvents(runDir, { tail }).events;
235
+ }
236
+ catch (err) {
237
+ // Mid-log corruption — surface as warning, keep the snapshot.
238
+ tailEvents = [];
239
+ if (!json) {
240
+ return {
241
+ exit: 1,
242
+ stdout: [],
243
+ stderr: [`[claude-autopilot] runs show: events.ndjson corrupt — ${formatErr(err)}`],
244
+ };
245
+ }
246
+ }
247
+ }
248
+ if (json) {
249
+ return maybeEnvelope('runs show', true, { exit: 0, stdout: [], stderr: [] }, {
250
+ runId: opts.runId,
251
+ state,
252
+ recovered,
253
+ events: tailEvents,
254
+ eventsTail: tail,
255
+ });
256
+ }
257
+ // Text mode — header + phase checklist.
258
+ const lines = [];
259
+ lines.push(`run ${state.runId} status=${state.status}${recovered ? ' (recovered)' : ''}`);
260
+ lines.push(` started: ${state.startedAt}`);
261
+ if (state.endedAt)
262
+ lines.push(` ended: ${state.endedAt}`);
263
+ lines.push(` cost: $${state.totalCostUSD.toFixed(4)}`);
264
+ lines.push(` cwd: ${state.cwd || '(unknown)'}`);
265
+ lines.push('');
266
+ lines.push('phases:');
267
+ for (const p of state.phases) {
268
+ lines.push(formatPhaseRow(p, p.index === state.currentPhaseIdx));
269
+ if (p.lastError) {
270
+ lines.push(` error: ${p.lastError}`);
271
+ }
272
+ if (p.externalRefs.length > 0) {
273
+ for (const r of p.externalRefs) {
274
+ lines.push(` ref: ${r.kind}=${r.id}${r.url ? ` (${r.url})` : ''}`);
275
+ }
276
+ }
277
+ }
278
+ if (opts.events) {
279
+ lines.push('');
280
+ lines.push(`events (last ${tailEvents.length}):`);
281
+ for (const ev of tailEvents) {
282
+ lines.push(` ${ev.seq.toString().padStart(4)} ${ev.ts} ${ev.event}`);
283
+ }
284
+ }
285
+ return { exit: 0, stdout: lines, stderr: [] };
286
+ }
287
+ function statusGlyph(status) {
288
+ switch (status) {
289
+ case 'succeeded':
290
+ return '[x]';
291
+ case 'failed':
292
+ return '[!]';
293
+ case 'running':
294
+ return '[>]';
295
+ case 'aborted':
296
+ return '[-]';
297
+ case 'skipped':
298
+ return '[~]';
299
+ case 'pending':
300
+ default:
301
+ return '[ ]';
302
+ }
303
+ }
304
+ function formatPhaseRow(p, isCurrent) {
305
+ const arrow = isCurrent ? ' <-' : '';
306
+ const cost = `$${p.costUSD.toFixed(4)}`;
307
+ const dur = p.durationMs !== undefined ? `${p.durationMs}ms` : '-';
308
+ return ` ${statusGlyph(p.status)} ${p.name.padEnd(14)} ${cost.padEnd(10)} ${dur.padEnd(8)} attempts=${p.attempts}${arrow}`;
309
+ }
310
+ /** `runs gc` — wraps gcRuns with confirmation. Default cutoff 30 days. With
311
+ * --dry-run, lists what would be removed without touching disk. */
312
+ export async function runRunsGc(opts) {
313
+ const cwd = opts.cwd ?? process.cwd();
314
+ const json = !!opts.json;
315
+ const olderThanDays = opts.olderThanDays ?? 30;
316
+ if (!Number.isFinite(olderThanDays) || olderThanDays < 0) {
317
+ const err = new GuardrailError(`--older-than-days must be a non-negative number (got ${olderThanDays})`, { code: 'invalid_config', provider: 'runs-cli', details: { olderThanDays } });
318
+ const result = {
319
+ exit: 1,
320
+ stdout: [],
321
+ stderr: [`[claude-autopilot] runs gc: ${formatErr(err)}`],
322
+ };
323
+ return maybeEnvelope('runs gc', json, result, { error: formatErr(err) });
324
+ }
325
+ // Always start with a dry-run pass so we can preview + ask.
326
+ const preview = gcRuns(cwd, { olderThanDays, dryRun: true });
327
+ if (preview.deleted.length === 0) {
328
+ const result = {
329
+ exit: 0,
330
+ stdout: [
331
+ `runs gc: nothing to delete (cutoff ${olderThanDays} days; ${preview.kept.length} kept, ${preview.skippedUnsafe.length} skipped unsafe)`,
332
+ ],
333
+ stderr: [],
334
+ };
335
+ return maybeEnvelope('runs gc', json, result, {
336
+ olderThanDays,
337
+ candidates: [],
338
+ deleted: [],
339
+ kept: preview.kept,
340
+ skippedUnsafe: preview.skippedUnsafe,
341
+ dryRun: true,
342
+ });
343
+ }
344
+ if (opts.dryRun) {
345
+ const lines = [
346
+ `runs gc (dry-run): would delete ${preview.deleted.length} run(s)`,
347
+ ...preview.deleted.map(id => ` - ${id}`),
348
+ `kept ${preview.kept.length}, skipped unsafe ${preview.skippedUnsafe.length}`,
349
+ ];
350
+ return maybeEnvelope('runs gc', json, { exit: 0, stdout: lines, stderr: [] }, {
351
+ olderThanDays,
352
+ candidates: preview.deleted,
353
+ deleted: [],
354
+ kept: preview.kept,
355
+ skippedUnsafe: preview.skippedUnsafe,
356
+ dryRun: true,
357
+ });
358
+ }
359
+ // Confirmation. --yes skips; --json implies non-interactive — we require
360
+ // --yes there to avoid blocking a CI invocation.
361
+ if (!opts.yes) {
362
+ if (json || !process.stdin.isTTY) {
363
+ const err = new GuardrailError(`non-interactive: pass --yes to confirm deletion of ${preview.deleted.length} run(s)`, {
364
+ code: 'invalid_config',
365
+ provider: 'runs-cli',
366
+ details: { candidates: preview.deleted },
367
+ });
368
+ const result = {
369
+ exit: 1,
370
+ stdout: [],
371
+ stderr: [`[claude-autopilot] runs gc: ${formatErr(err)}`],
372
+ };
373
+ return maybeEnvelope('runs gc', json, result, {
374
+ error: formatErr(err),
375
+ candidates: preview.deleted,
376
+ });
377
+ }
378
+ const confirmed = await confirmInteractive(`Delete ${preview.deleted.length} run(s) older than ${olderThanDays} days? [y/N] `);
379
+ if (!confirmed) {
380
+ return {
381
+ exit: 0,
382
+ stdout: ['runs gc: aborted'],
383
+ stderr: [],
384
+ };
385
+ }
386
+ }
387
+ // Real pass.
388
+ const real = gcRuns(cwd, { olderThanDays });
389
+ const lines = [
390
+ `runs gc: deleted ${real.deleted.length} run(s)`,
391
+ ...real.deleted.map(id => ` - ${id}`),
392
+ `kept ${real.kept.length}, skipped unsafe ${real.skippedUnsafe.length}`,
393
+ ];
394
+ return maybeEnvelope('runs gc', json, { exit: 0, stdout: lines, stderr: [] }, {
395
+ olderThanDays,
396
+ deleted: real.deleted,
397
+ kept: real.kept,
398
+ skippedUnsafe: real.skippedUnsafe,
399
+ dryRun: false,
400
+ });
401
+ }
402
+ async function confirmInteractive(prompt) {
403
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
404
+ try {
405
+ const answer = await new Promise(resolve => rl.question(prompt, resolve));
406
+ return /^y(es)?$/i.test(answer.trim());
407
+ }
408
+ finally {
409
+ rl.close();
410
+ }
411
+ }
412
+ const TERMINAL_STATUSES = new Set(['success', 'failed', 'aborted']);
413
+ /** `runs delete <id>` — explicit single-run delete. Refuses non-terminal
414
+ * status without --force; refuses if the run lock is currently held by
415
+ * another writer.
416
+ *
417
+ * We acquire the lock for the duration of the delete so we never race a
418
+ * concurrent writer. Lock acquisition uses a tiny timeout — we want
419
+ * fail-fast over blocking. */
420
+ export async function runRunsDelete(opts) {
421
+ const cwd = opts.cwd ?? process.cwd();
422
+ const json = !!opts.json;
423
+ try {
424
+ assertValidRunId(opts.runId);
425
+ }
426
+ catch (err) {
427
+ const result = {
428
+ exit: 1,
429
+ stdout: [],
430
+ stderr: [`[claude-autopilot] runs delete: ${formatErr(err)}`],
431
+ };
432
+ return maybeEnvelope('runs delete', json, result, { error: formatErr(err) });
433
+ }
434
+ const runDir = runDirFor(cwd, opts.runId);
435
+ if (!fs.existsSync(runDir)) {
436
+ const err = new GuardrailError(`run not found: ${opts.runId}`, {
437
+ code: 'not_found',
438
+ provider: 'runs-cli',
439
+ details: { runId: opts.runId, runDir },
440
+ });
441
+ const result = {
442
+ exit: 1,
443
+ stdout: [],
444
+ stderr: [`[claude-autopilot] runs delete: ${formatErr(err)}`],
445
+ };
446
+ return maybeEnvelope('runs delete', json, result, {
447
+ error: formatErr(err),
448
+ runId: opts.runId,
449
+ });
450
+ }
451
+ // Refuse if non-terminal without --force.
452
+ let status = 'unknown';
453
+ try {
454
+ const snap = readStateSnapshot(runDir);
455
+ if (snap)
456
+ status = snap.status;
457
+ }
458
+ catch {
459
+ // Treat corrupt as unknown — we still let --force win below.
460
+ }
461
+ if (!opts.force && status !== 'unknown' && !TERMINAL_STATUSES.has(status)) {
462
+ const err = new GuardrailError(`run ${opts.runId} status=${status} is not terminal — refusing delete without --force`, {
463
+ code: 'invalid_config',
464
+ provider: 'runs-cli',
465
+ details: { runId: opts.runId, status },
466
+ });
467
+ const result = {
468
+ exit: 1,
469
+ stdout: [],
470
+ stderr: [`[claude-autopilot] runs delete: ${formatErr(err)}`],
471
+ };
472
+ return maybeEnvelope('runs delete', json, result, {
473
+ error: formatErr(err),
474
+ runId: opts.runId,
475
+ status,
476
+ });
477
+ }
478
+ // Lock acquisition. If the lock is held we surface lock_held.
479
+ let lock;
480
+ try {
481
+ lock = await acquireRunLock(runDir, { retries: 0 });
482
+ }
483
+ catch (err) {
484
+ const result = {
485
+ exit: 1,
486
+ stdout: [],
487
+ stderr: [`[claude-autopilot] runs delete: ${formatErr(err)}`],
488
+ };
489
+ return maybeEnvelope('runs delete', json, result, {
490
+ error: formatErr(err),
491
+ runId: opts.runId,
492
+ });
493
+ }
494
+ try {
495
+ fs.rmSync(runDir, { recursive: true, force: true });
496
+ }
497
+ catch (err) {
498
+ await lock.release().catch(() => { });
499
+ const result = {
500
+ exit: 1,
501
+ stdout: [],
502
+ stderr: [`[claude-autopilot] runs delete: rm failed: ${formatErr(err)}`],
503
+ };
504
+ return maybeEnvelope('runs delete', json, result, {
505
+ error: formatErr(err),
506
+ runId: opts.runId,
507
+ });
508
+ }
509
+ // Refresh the index — best-effort.
510
+ try {
511
+ rebuildIndex(cwd);
512
+ }
513
+ catch { /* index is cache */ }
514
+ // The lock handle's underlying file is gone — release() will no-op gracefully.
515
+ await lock.release().catch(() => { });
516
+ return maybeEnvelope('runs delete', json, {
517
+ exit: 0,
518
+ stdout: [`runs delete: removed ${opts.runId}`],
519
+ stderr: [],
520
+ }, { runId: opts.runId, deleted: true, status });
521
+ }
522
+ /** `run resume <id>` — Phase 3 LOOKUP ONLY.
523
+ *
524
+ * This verb identifies which phase a future resume would pick up from and
525
+ * the decision the engine would make per the spec's idempotency table. It
526
+ * does NOT execute the phase — that wires in Phase 6+ once the budget
527
+ * enforcer (Phase 4) and the JSON event stream (Phase 5) are in place.
528
+ *
529
+ * Decision rules (mirror `runPhase` in src/core/run-state/phase-runner.ts):
530
+ * - already-complete : run.status === 'success' or every phase succeeded
531
+ * - skip-idempotent : nextPhase has a prior phase.success AND idempotent
532
+ * - needs-human : nextPhase has a prior phase.success AND side-effects
533
+ * - retry : default (no prior success — first attempt or retry
534
+ * of a failed attempt) */
535
+ export async function runRunResume(opts) {
536
+ const cwd = opts.cwd ?? process.cwd();
537
+ const json = !!opts.json;
538
+ try {
539
+ assertValidRunId(opts.runId);
540
+ }
541
+ catch (err) {
542
+ const result = {
543
+ exit: 1,
544
+ stdout: [],
545
+ stderr: [`[claude-autopilot] run resume: ${formatErr(err)}`],
546
+ };
547
+ return maybeEnvelope('run resume', json, result, { error: formatErr(err) });
548
+ }
549
+ const runDir = runDirFor(cwd, opts.runId);
550
+ if (!fs.existsSync(runDir)) {
551
+ const err = new GuardrailError(`run not found: ${opts.runId}`, {
552
+ code: 'not_found',
553
+ provider: 'runs-cli',
554
+ details: { runId: opts.runId, runDir },
555
+ });
556
+ const result = {
557
+ exit: 1,
558
+ stdout: [],
559
+ stderr: [`[claude-autopilot] run resume: ${formatErr(err)}`],
560
+ };
561
+ return maybeEnvelope('run resume', json, result, {
562
+ error: formatErr(err),
563
+ runId: opts.runId,
564
+ });
565
+ }
566
+ // Always replay events in-memory so the lookup is cheap and never mutates.
567
+ let state;
568
+ try {
569
+ const fromSnap = readStateSnapshot(runDir);
570
+ if (fromSnap)
571
+ state = fromSnap;
572
+ else
573
+ state = foldEvents(runDir, readEvents(runDir).events);
574
+ }
575
+ catch (err) {
576
+ // Fall back to events replay; if THAT fails, surface.
577
+ try {
578
+ state = foldEvents(runDir, readEvents(runDir).events);
579
+ }
580
+ catch {
581
+ const result = {
582
+ exit: 1,
583
+ stdout: [],
584
+ stderr: [`[claude-autopilot] run resume: ${formatErr(err)}`],
585
+ };
586
+ return maybeEnvelope('run resume', json, result, {
587
+ error: formatErr(err),
588
+ runId: opts.runId,
589
+ });
590
+ }
591
+ }
592
+ const lookup = computeResumeLookup(state, opts.fromPhase);
593
+ // Validate --from-phase if provided.
594
+ if (opts.fromPhase && !state.phases.some(p => p.name === opts.fromPhase)) {
595
+ const err = new GuardrailError(`--from-phase "${opts.fromPhase}" is not a phase of run ${opts.runId}`, {
596
+ code: 'invalid_config',
597
+ provider: 'runs-cli',
598
+ details: { fromPhase: opts.fromPhase, phases: state.phases.map(p => p.name) },
599
+ });
600
+ const result = {
601
+ exit: 1,
602
+ stdout: [],
603
+ stderr: [`[claude-autopilot] run resume: ${formatErr(err)}`],
604
+ };
605
+ return maybeEnvelope('run resume', json, result, {
606
+ error: formatErr(err),
607
+ runId: opts.runId,
608
+ });
609
+ }
610
+ if (json) {
611
+ return maybeEnvelope('run resume', true, { exit: 0, stdout: [], stderr: [] }, {
612
+ ...lookup,
613
+ lookupOnly: true,
614
+ note: 'Phase 3 of v6 is lookup-only. Execution wires in Phase 6+.',
615
+ });
616
+ }
617
+ const lines = [];
618
+ lines.push(`run ${lookup.runId} status=${lookup.status}`);
619
+ lines.push(` currentPhase: ${lookup.currentPhase ?? '(none)'}`);
620
+ lines.push(` nextPhase: ${lookup.nextPhase ?? '(none)'}`);
621
+ lines.push(` decision: ${lookup.decision}`);
622
+ lines.push(` reason: ${lookup.reason}`);
623
+ if (lookup.externalRefs.length > 0) {
624
+ lines.push(' externalRefs:');
625
+ for (const r of lookup.externalRefs) {
626
+ lines.push(` ${r.kind}=${r.id}${r.url ? ` (${r.url})` : ''}`);
627
+ }
628
+ }
629
+ lines.push('');
630
+ lines.push('NOTE: this is a lookup-only verb in v6 Phase 3.');
631
+ lines.push(' Actual phase execution wires in Phase 6+. Use it to confirm');
632
+ lines.push(' the engine would do the right thing before that lands.');
633
+ return { exit: 0, stdout: lines, stderr: [] };
634
+ }
635
+ /** Pure projection over a RunState that decides the next phase + replay rule.
636
+ * Exported for tests. */
637
+ export function computeResumeLookup(state, fromPhase) {
638
+ const externalRefs = [];
639
+ for (const p of state.phases)
640
+ externalRefs.push(...p.externalRefs);
641
+ // Already-complete short-circuit: every phase succeeded OR run.status is
642
+ // success. Either condition is enough.
643
+ if (state.status === 'success' || state.phases.every(p => p.status === 'succeeded')) {
644
+ const last = state.phases[state.phases.length - 1];
645
+ return {
646
+ runId: state.runId,
647
+ status: state.status,
648
+ currentPhase: last?.name ?? null,
649
+ nextPhase: null,
650
+ decision: 'already-complete',
651
+ reason: 'all phases succeeded — nothing to resume',
652
+ externalRefs,
653
+ };
654
+ }
655
+ // Find the resume target. Either the explicit --from-phase or the first
656
+ // non-succeeded phase by index.
657
+ let target;
658
+ if (fromPhase) {
659
+ target = state.phases.find(p => p.name === fromPhase);
660
+ }
661
+ else {
662
+ target = state.phases.find(p => p.status !== 'succeeded');
663
+ }
664
+ if (!target) {
665
+ return {
666
+ runId: state.runId,
667
+ status: state.status,
668
+ currentPhase: null,
669
+ nextPhase: null,
670
+ decision: 'already-complete',
671
+ reason: 'no resumable phase identified',
672
+ externalRefs,
673
+ };
674
+ }
675
+ const currentName = state.phases[state.currentPhaseIdx]?.name ?? null;
676
+ // Phase 6 — delegate to the canonical decideReplay() so the CLI
677
+ // prediction matches what runPhase will actually do. This is "lookup
678
+ // mode" — we pass an empty readbacks array, which (per the matrix)
679
+ // collapses every prior-success-with-side-effects case to needs-human
680
+ // because we can't perform a live readback from inside the CLI lookup.
681
+ // That's the right answer: surface the question to the user before
682
+ // actual execution. The CLI prediction's `skip-idempotent` /
683
+ // `already-complete` decisions are convenience aliases over decideReplay's
684
+ // `skip-already-applied` so existing consumers keep their vocabulary.
685
+ const hasPriorSuccess = target.status === 'succeeded';
686
+ const decision = decideReplay({
687
+ phaseName: target.name,
688
+ hasPriorSuccess,
689
+ priorAttempts: target.attempts,
690
+ idempotent: target.idempotent,
691
+ hasSideEffects: target.hasSideEffects,
692
+ externalRefs: target.externalRefs,
693
+ readbacks: [], // pure-state lookup; live readbacks happen inside runPhase
694
+ forceReplay: false,
695
+ });
696
+ // Bugbot LOW (PR #91): exhaustive switch — no `default` branch so TypeScript
697
+ // catches a missing case at compile time if a new ReplayDecisionKind variant
698
+ // is added. The `never` assignment is the standard pattern (mirrors the
699
+ // foldEvents switch in events.ts).
700
+ let mappedDecision;
701
+ switch (decision.decision) {
702
+ case 'retry':
703
+ mappedDecision = 'retry';
704
+ break;
705
+ case 'needs-human':
706
+ case 'abort':
707
+ mappedDecision = 'needs-human';
708
+ break;
709
+ case 'skip-already-applied':
710
+ // Map to the existing CLI vocabulary: idempotent phases keep their
711
+ // skip-idempotent label; everything else surfaces as already-complete
712
+ // so existing CLI consumers don't need to learn a new verb.
713
+ mappedDecision = target.idempotent ? 'skip-idempotent' : 'already-complete';
714
+ break;
715
+ default: {
716
+ const _exhaustive = decision.decision;
717
+ throw new Error(`unreachable ReplayDecisionKind: ${String(_exhaustive)}`);
718
+ }
719
+ }
720
+ return {
721
+ runId: state.runId,
722
+ status: state.status,
723
+ currentPhase: currentName,
724
+ nextPhase: target.name,
725
+ decision: mappedDecision,
726
+ reason: decision.reason,
727
+ externalRefs: target.externalRefs.length > 0 ? target.externalRefs : externalRefs,
728
+ };
729
+ }
730
+ /** `runs doctor` — replay events.ndjson per run, compare against state.json,
731
+ * report drift. With --fix, rewrite state.json from the replay where drift
732
+ * exists.
733
+ *
734
+ * Drift categories:
735
+ * snapshot-vs-replay : both readable but disagree on a key field
736
+ * snapshot-missing : state.json absent, replay successful
737
+ * snapshot-corrupt : state.json present but unparseable
738
+ * events-corrupt : events.ndjson can't be folded (bigger problem)
739
+ */
740
+ export async function runRunsDoctor(opts) {
741
+ const cwd = opts.cwd ?? process.cwd();
742
+ const json = !!opts.json;
743
+ const root = runsRoot(cwd);
744
+ if (!fs.existsSync(root)) {
745
+ return maybeEnvelope('runs doctor', json, { exit: 0, stdout: ['runs doctor: no runs directory.'], stderr: [] }, { runs: [] });
746
+ }
747
+ // Decide the run set.
748
+ let runIds;
749
+ if (opts.runId) {
750
+ try {
751
+ assertValidRunId(opts.runId);
752
+ }
753
+ catch (err) {
754
+ const result = {
755
+ exit: 1,
756
+ stdout: [],
757
+ stderr: [`[claude-autopilot] runs doctor: ${formatErr(err)}`],
758
+ };
759
+ return maybeEnvelope('runs doctor', json, result, { error: formatErr(err) });
760
+ }
761
+ runIds = [opts.runId];
762
+ }
763
+ else {
764
+ runIds = fs
765
+ .readdirSync(root, { withFileTypes: true })
766
+ .filter(d => d.isDirectory())
767
+ .map(d => d.name)
768
+ .filter(isValidULID);
769
+ }
770
+ const reports = [];
771
+ let driftCount = 0;
772
+ for (const runId of runIds) {
773
+ const runDir = path.join(root, runId);
774
+ if (!fs.existsSync(runDir)) {
775
+ reports.push({ runId, drift: 'snapshot-missing', details: 'run dir not found' });
776
+ driftCount += 1;
777
+ continue;
778
+ }
779
+ let snapshot = null;
780
+ let snapErr = null;
781
+ try {
782
+ snapshot = readStateSnapshot(runDir);
783
+ }
784
+ catch (err) {
785
+ snapErr = formatErr(err);
786
+ }
787
+ let replayed = null;
788
+ let replayErr = null;
789
+ try {
790
+ const evRead = readEvents(runDir);
791
+ replayed = foldEvents(runDir, evRead.events);
792
+ }
793
+ catch (err) {
794
+ replayErr = formatErr(err);
795
+ }
796
+ if (replayErr) {
797
+ reports.push({ runId, drift: 'events-corrupt', details: replayErr });
798
+ driftCount += 1;
799
+ continue;
800
+ }
801
+ if (!snapshot && !snapErr) {
802
+ // snapshot missing
803
+ reports.push({ runId, drift: 'snapshot-missing', details: 'state.json absent' });
804
+ driftCount += 1;
805
+ if (opts.fix && replayed) {
806
+ try {
807
+ writeStateSnapshot(runDir, replayed);
808
+ reports[reports.length - 1].fixed = true;
809
+ }
810
+ catch (err) {
811
+ reports[reports.length - 1].details = `fix failed: ${formatErr(err)}`;
812
+ }
813
+ }
814
+ continue;
815
+ }
816
+ if (snapErr) {
817
+ reports.push({ runId, drift: 'snapshot-corrupt', details: snapErr });
818
+ driftCount += 1;
819
+ if (opts.fix && replayed) {
820
+ try {
821
+ writeStateSnapshot(runDir, replayed);
822
+ reports[reports.length - 1].fixed = true;
823
+ }
824
+ catch (err) {
825
+ reports[reports.length - 1].details = `fix failed: ${formatErr(err)}`;
826
+ }
827
+ }
828
+ continue;
829
+ }
830
+ // Both readable — compare key fields.
831
+ const drift = diffStates(snapshot, replayed);
832
+ if (drift) {
833
+ reports.push({ runId, drift: 'snapshot-vs-replay', details: drift });
834
+ driftCount += 1;
835
+ if (opts.fix && replayed) {
836
+ try {
837
+ writeStateSnapshot(runDir, replayed);
838
+ reports[reports.length - 1].fixed = true;
839
+ }
840
+ catch (err) {
841
+ reports[reports.length - 1].details = `${drift}; fix failed: ${formatErr(err)}`;
842
+ }
843
+ }
844
+ }
845
+ else {
846
+ reports.push({ runId, drift: 'none' });
847
+ }
848
+ }
849
+ const exit = driftCount > 0 && !opts.fix ? 1 : 0;
850
+ if (json) {
851
+ return maybeEnvelope('runs doctor', true, { exit, stdout: [], stderr: [] }, { runs: reports, driftCount, fixApplied: !!opts.fix });
852
+ }
853
+ const lines = [];
854
+ if (reports.length === 0) {
855
+ lines.push('runs doctor: no runs found.');
856
+ }
857
+ else {
858
+ for (const r of reports) {
859
+ const tag = r.drift === 'none' ? 'OK' : r.drift.toUpperCase();
860
+ const fixedNote = r.fixed ? ' (fixed)' : '';
861
+ lines.push(` ${tag.padEnd(20)} ${r.runId}${fixedNote}${r.details ? ` — ${r.details}` : ''}`);
862
+ }
863
+ lines.push('');
864
+ lines.push(`runs doctor: ${reports.length} run(s) checked, ${driftCount} drift finding(s)`);
865
+ if (driftCount > 0 && !opts.fix) {
866
+ lines.push(' hint: re-run with --fix to rewrite state.json from events.ndjson');
867
+ }
868
+ }
869
+ return { exit, stdout: lines, stderr: [] };
870
+ }
871
+ /** Diff two RunStates on key fields. Returns a one-line description of the
872
+ * first divergence or null if equivalent. */
873
+ function diffStates(a, b) {
874
+ if (a.runId !== b.runId)
875
+ return `runId mismatch (${a.runId} vs ${b.runId})`;
876
+ if (a.status !== b.status)
877
+ return `status mismatch (${a.status} vs ${b.status})`;
878
+ if (a.lastEventSeq !== b.lastEventSeq) {
879
+ return `lastEventSeq mismatch (${a.lastEventSeq} vs ${b.lastEventSeq})`;
880
+ }
881
+ // Cost compared with a small epsilon for float jitter.
882
+ if (Math.abs(a.totalCostUSD - b.totalCostUSD) > 1e-9) {
883
+ return `totalCostUSD mismatch (${a.totalCostUSD} vs ${b.totalCostUSD})`;
884
+ }
885
+ if (a.phases.length !== b.phases.length) {
886
+ return `phase count mismatch (${a.phases.length} vs ${b.phases.length})`;
887
+ }
888
+ for (let i = 0; i < a.phases.length; i++) {
889
+ const pa = a.phases[i];
890
+ const pb = b.phases[i];
891
+ if (pa.status !== pb.status) {
892
+ return `phases[${i}] (${pa.name}) status mismatch (${pa.status} vs ${pb.status})`;
893
+ }
894
+ if (pa.attempts !== pb.attempts) {
895
+ return `phases[${i}] (${pa.name}) attempts mismatch (${pa.attempts} vs ${pb.attempts})`;
896
+ }
897
+ }
898
+ return null;
899
+ }
900
+ // `statePath` is re-exported for convenience to keep CLI imports tidy.
901
+ export { statePath };
902
+ //# sourceMappingURL=runs.js.map