@delegance/claude-autopilot 5.2.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 (130) hide show
  1. package/CHANGELOG.md +1027 -1
  2. package/README.md +104 -17
  3. package/dist/src/adapters/council/claude.js +2 -1
  4. package/dist/src/adapters/council/openai.js +14 -7
  5. package/dist/src/adapters/deploy/_http.d.ts +43 -0
  6. package/dist/src/adapters/deploy/_http.js +99 -0
  7. package/dist/src/adapters/deploy/fly.d.ts +206 -0
  8. package/dist/src/adapters/deploy/fly.js +696 -0
  9. package/dist/src/adapters/deploy/generic.d.ts +39 -0
  10. package/dist/src/adapters/deploy/generic.js +98 -0
  11. package/dist/src/adapters/deploy/index.d.ts +15 -0
  12. package/dist/src/adapters/deploy/index.js +78 -0
  13. package/dist/src/adapters/deploy/render.d.ts +181 -0
  14. package/dist/src/adapters/deploy/render.js +550 -0
  15. package/dist/src/adapters/deploy/types.d.ts +221 -0
  16. package/dist/src/adapters/deploy/types.js +15 -0
  17. package/dist/src/adapters/deploy/vercel.d.ts +143 -0
  18. package/dist/src/adapters/deploy/vercel.js +426 -0
  19. package/dist/src/adapters/pricing.d.ts +36 -0
  20. package/dist/src/adapters/pricing.js +40 -0
  21. package/dist/src/adapters/review-engine/claude.js +2 -1
  22. package/dist/src/adapters/review-engine/codex.js +12 -8
  23. package/dist/src/adapters/review-engine/gemini.js +2 -1
  24. package/dist/src/adapters/review-engine/openai-compatible.js +2 -1
  25. package/dist/src/adapters/sdk-loader.d.ts +15 -0
  26. package/dist/src/adapters/sdk-loader.js +77 -0
  27. package/dist/src/cli/autopilot.d.ts +71 -0
  28. package/dist/src/cli/autopilot.js +735 -0
  29. package/dist/src/cli/brainstorm.d.ts +23 -0
  30. package/dist/src/cli/brainstorm.js +131 -0
  31. package/dist/src/cli/costs.d.ts +15 -1
  32. package/dist/src/cli/costs.js +99 -10
  33. package/dist/src/cli/deploy.d.ts +71 -0
  34. package/dist/src/cli/deploy.js +539 -0
  35. package/dist/src/cli/fix.d.ts +18 -0
  36. package/dist/src/cli/fix.js +105 -11
  37. package/dist/src/cli/help-text.d.ts +52 -0
  38. package/dist/src/cli/help-text.js +400 -0
  39. package/dist/src/cli/implement.d.ts +91 -0
  40. package/dist/src/cli/implement.js +196 -0
  41. package/dist/src/cli/index.js +784 -222
  42. package/dist/src/cli/json-envelope.d.ts +187 -0
  43. package/dist/src/cli/json-envelope.js +270 -0
  44. package/dist/src/cli/json-mode.d.ts +33 -0
  45. package/dist/src/cli/json-mode.js +201 -0
  46. package/dist/src/cli/migrate.d.ts +111 -0
  47. package/dist/src/cli/migrate.js +305 -0
  48. package/dist/src/cli/plan.d.ts +81 -0
  49. package/dist/src/cli/plan.js +149 -0
  50. package/dist/src/cli/pr.d.ts +106 -0
  51. package/dist/src/cli/pr.js +191 -19
  52. package/dist/src/cli/preflight.js +102 -1
  53. package/dist/src/cli/review.d.ts +27 -0
  54. package/dist/src/cli/review.js +126 -0
  55. package/dist/src/cli/runs-watch-renderer.d.ts +45 -0
  56. package/dist/src/cli/runs-watch-renderer.js +275 -0
  57. package/dist/src/cli/runs-watch.d.ts +41 -0
  58. package/dist/src/cli/runs-watch.js +395 -0
  59. package/dist/src/cli/runs.d.ts +122 -0
  60. package/dist/src/cli/runs.js +902 -0
  61. package/dist/src/cli/scan.d.ts +93 -0
  62. package/dist/src/cli/scan.js +166 -40
  63. package/dist/src/cli/spec.d.ts +66 -0
  64. package/dist/src/cli/spec.js +132 -0
  65. package/dist/src/cli/validate.d.ts +29 -0
  66. package/dist/src/cli/validate.js +131 -0
  67. package/dist/src/core/config/schema.d.ts +43 -0
  68. package/dist/src/core/config/schema.js +25 -0
  69. package/dist/src/core/config/types.d.ts +17 -0
  70. package/dist/src/core/council/runner.d.ts +10 -1
  71. package/dist/src/core/council/runner.js +25 -3
  72. package/dist/src/core/council/types.d.ts +7 -0
  73. package/dist/src/core/errors.d.ts +1 -1
  74. package/dist/src/core/errors.js +12 -0
  75. package/dist/src/core/logging/redaction.d.ts +13 -0
  76. package/dist/src/core/logging/redaction.js +20 -0
  77. package/dist/src/core/migrate/detector-rules.js +6 -0
  78. package/dist/src/core/migrate/schema-validator.js +22 -1
  79. package/dist/src/core/phases/static-rules.d.ts +5 -1
  80. package/dist/src/core/phases/static-rules.js +2 -5
  81. package/dist/src/core/run-state/budget.d.ts +88 -0
  82. package/dist/src/core/run-state/budget.js +141 -0
  83. package/dist/src/core/run-state/cli-internal.d.ts +21 -0
  84. package/dist/src/core/run-state/cli-internal.js +174 -0
  85. package/dist/src/core/run-state/events.d.ts +59 -0
  86. package/dist/src/core/run-state/events.js +504 -0
  87. package/dist/src/core/run-state/lock.d.ts +61 -0
  88. package/dist/src/core/run-state/lock.js +206 -0
  89. package/dist/src/core/run-state/phase-context.d.ts +60 -0
  90. package/dist/src/core/run-state/phase-context.js +108 -0
  91. package/dist/src/core/run-state/phase-registry.d.ts +137 -0
  92. package/dist/src/core/run-state/phase-registry.js +162 -0
  93. package/dist/src/core/run-state/phase-runner.d.ts +80 -0
  94. package/dist/src/core/run-state/phase-runner.js +447 -0
  95. package/dist/src/core/run-state/provider-readback.d.ts +130 -0
  96. package/dist/src/core/run-state/provider-readback.js +426 -0
  97. package/dist/src/core/run-state/replay-decision.d.ts +69 -0
  98. package/dist/src/core/run-state/replay-decision.js +144 -0
  99. package/dist/src/core/run-state/resolve-engine.d.ts +100 -0
  100. package/dist/src/core/run-state/resolve-engine.js +190 -0
  101. package/dist/src/core/run-state/resume-preflight.d.ts +66 -0
  102. package/dist/src/core/run-state/resume-preflight.js +116 -0
  103. package/dist/src/core/run-state/run-phase-with-lifecycle.d.ts +73 -0
  104. package/dist/src/core/run-state/run-phase-with-lifecycle.js +186 -0
  105. package/dist/src/core/run-state/runs.d.ts +57 -0
  106. package/dist/src/core/run-state/runs.js +288 -0
  107. package/dist/src/core/run-state/snapshot.d.ts +14 -0
  108. package/dist/src/core/run-state/snapshot.js +114 -0
  109. package/dist/src/core/run-state/state.d.ts +40 -0
  110. package/dist/src/core/run-state/state.js +164 -0
  111. package/dist/src/core/run-state/types.d.ts +278 -0
  112. package/dist/src/core/run-state/types.js +13 -0
  113. package/dist/src/core/run-state/ulid.d.ts +11 -0
  114. package/dist/src/core/run-state/ulid.js +95 -0
  115. package/dist/src/core/schema-alignment/extractor/index.d.ts +1 -1
  116. package/dist/src/core/schema-alignment/extractor/index.js +2 -2
  117. package/dist/src/core/schema-alignment/extractor/prisma.d.ts +13 -1
  118. package/dist/src/core/schema-alignment/extractor/prisma.js +65 -10
  119. package/dist/src/core/schema-alignment/git-history.d.ts +19 -0
  120. package/dist/src/core/schema-alignment/git-history.js +53 -0
  121. package/dist/src/core/static-rules/rules/brand-tokens.js +2 -2
  122. package/dist/src/core/static-rules/rules/schema-alignment.js +14 -4
  123. package/package.json +9 -5
  124. package/scripts/autoregress.ts +3 -2
  125. package/skills/claude-autopilot.md +1 -1
  126. package/skills/make-interfaces-feel-better/SKILL.md +104 -0
  127. package/skills/migrate/SKILL.md +193 -47
  128. package/skills/simplify-ui/SKILL.md +103 -0
  129. package/skills/ui/SKILL.md +117 -0
  130. package/skills/ui-ux-pro-max/SKILL.md +90 -0
@@ -0,0 +1,186 @@
1
+ // src/core/run-state/run-phase-with-lifecycle.ts
2
+ //
3
+ // v6.0.6 — extract the lifecycle wrapper that's been duplicated across
4
+ // every wrapped CLI verb (`scan`, `costs`, `fix`, `brainstorm`, `spec`,
5
+ // `plan`, `review`, `validate`). The pattern is mechanical:
6
+ //
7
+ // 1. If engine-off → run the legacy phase body via `runEngineOff()` and
8
+ // return its result.
9
+ // 2. If engine-on → createRun → optional run.warning for invalid env →
10
+ // runPhase → emit run.complete (success or failed) → refresh state.json
11
+ // → release lock (best effort, in finally).
12
+ //
13
+ // This helper sits ON TOP of `runPhase()` from `phase-runner.ts` — it does
14
+ // not replace it. Callers continue to define their own `RunPhase<I, O>` with
15
+ // per-phase `idempotent` / `hasSideEffects` / `run` and pass it in.
16
+ //
17
+ // Why now: with 8 of 10 phases wrapped (the v6.0.5 milestone), the pattern
18
+ // is fully evidenced. The remaining 3 phases (`implement`, `migrate`, `pr`)
19
+ // are side-effecting and need externalRefs — those will inform a v6.0.7+
20
+ // extension to this helper but won't change its core shape. Doing the
21
+ // extraction now means those 3 wraps build against the helper instead of
22
+ // re-introducing the boilerplate.
23
+ //
24
+ // What this helper does NOT do:
25
+ // - Print success banners — rendering stays in the caller.
26
+ // - Decide engine-off behavior — that's `runEngineOff`, supplied by the
27
+ // caller (typically a thin closure over the phase body).
28
+ // - Plumb externalRefs / readback — the underlying `runPhase()` already
29
+ // handles those. This helper just owns the run-level lifecycle events.
30
+ //
31
+ // Future extension (v6.0.7+): `implement` / `migrate` / `pr` need
32
+ // externalRef ledger entries (`git-remote-push`, `migration-version`,
33
+ // `github-pr`). The helper's `phase.run` already receives `ctx` so
34
+ // `ctx.emitExternalRef()` works without changes here. If a future PR needs
35
+ // to fan-in run-wide externalRefs from multiple phases (multi-phase
36
+ // pipelines, e.g. autopilot orchestrator), the signature can grow a
37
+ // `phases: RunPhase[]` overload — but the single-phase shape stays identical.
38
+ import { createRun } from "./runs.js";
39
+ import { runPhase } from "./phase-runner.js";
40
+ import { appendEvent, replayState } from "./events.js";
41
+ import { writeStateSnapshot } from "./state.js";
42
+ import { resolveEngineEnabled, emitEngineOffDeprecationWarning, } from "./resolve-engine.js";
43
+ // Inline ANSI codes — same shape every wrapped verb uses. Kept here so the
44
+ // helper doesn't depend on a verb-local `fmt`. The error message format
45
+ // (`[<phase>] engine: phase failed — <msg>` + dim inspect hint) is
46
+ // byte-for-byte identical to what every wrapped phase printed pre-extract.
47
+ const ANSI_RESET = '\x1b[0m';
48
+ const ANSI_DIM = '\x1b[2m';
49
+ const ANSI_RED = '\x1b[31m';
50
+ /** Drive a single-phase engine run with full lifecycle instrumentation,
51
+ * OR fall through to the legacy `runEngineOff` callback when the engine
52
+ * is disabled by config / CLI / env precedence.
53
+ *
54
+ * Engine-on lifecycle (in order):
55
+ * createRun → (optional run.warning for invalid env) → runPhase →
56
+ * run.complete (success or failed) → refresh state.json → release lock.
57
+ *
58
+ * On phase failure the helper:
59
+ * 1. Emits `run.complete` with `status: 'failed'`.
60
+ * 2. Refreshes state.json from the replayed events.
61
+ * 3. Prints the legacy `[<phase>] engine: phase failed — <msg>` banner
62
+ * to stderr (byte-for-byte identical to the inline pattern that
63
+ * lived in 8 of 8 wrapped verbs pre-v6.0.6).
64
+ * 4. Releases the lock and re-throws so the caller can return its
65
+ * legacy non-zero exit code.
66
+ *
67
+ * The lock release in `finally` is best-effort. `release()` is idempotent
68
+ * (the runs lock module accepts double-release without throwing), so the
69
+ * catch block does not need to release the lock itself — `finally` covers
70
+ * both the success and failure exit paths. */
71
+ export async function runPhaseWithLifecycle(opts) {
72
+ const { cwd, phase, input, config, cliEngine, envEngine, runEngineOff } = opts;
73
+ // Resolve engine via the canonical precedence (CLI > env > config >
74
+ // built-in default). The resolver is pure — same inputs always produce
75
+ // the same decision. We DO consult the loaded config's `engine.enabled`
76
+ // here so the helper's caller doesn't have to repeat the conditional
77
+ // spread that every wrapped verb wrote inline.
78
+ const engineResolved = resolveEngineEnabled({
79
+ ...(cliEngine !== undefined ? { cliEngine } : {}),
80
+ ...(envEngine !== undefined ? { envValue: envEngine } : {}),
81
+ ...(typeof config.engine?.enabled === 'boolean'
82
+ ? { configEnabled: config.engine.enabled }
83
+ : {}),
84
+ });
85
+ if (!engineResolved.enabled) {
86
+ // Engine off — call the caller's legacy path. No run dir, no events,
87
+ // no lifecycle work. Behavior is byte-for-byte identical to pre-engine
88
+ // versions of the verb. v6.1+ emits a one-line stderr deprecation
89
+ // notice when the user explicitly opted out (CLI / env / config); the
90
+ // v6.1 default is `enabled: true`, so a `'default'` source can't reach
91
+ // this branch and the deprecation helper no-ops on the `enabled: true`
92
+ // path. v7 removes the opt-out entirely.
93
+ emitEngineOffDeprecationWarning(engineResolved);
94
+ const output = await runEngineOff();
95
+ return { output, runId: null, runDir: null };
96
+ }
97
+ // Engine on — full lifecycle. Mirrors the pre-v6.0.6 inline shape that
98
+ // every wrapped verb duplicated.
99
+ const created = await createRun({
100
+ cwd,
101
+ phases: [phase.name],
102
+ config: {
103
+ engine: { enabled: true, source: engineResolved.source },
104
+ ...(engineResolved.invalidEnvValue !== undefined
105
+ ? { invalidEnvValue: engineResolved.invalidEnvValue }
106
+ : {}),
107
+ },
108
+ });
109
+ if (engineResolved.invalidEnvValue !== undefined) {
110
+ // Surface the invalid env value as a typed warning so observers
111
+ // (`runs show <id> --events`) can attribute the fallthrough.
112
+ appendEvent(created.runDir, {
113
+ event: 'run.warning',
114
+ message: `invalid CLAUDE_AUTOPILOT_ENGINE=${JSON.stringify(engineResolved.invalidEnvValue)} ignored`,
115
+ details: { resolution: engineResolved },
116
+ }, { writerId: created.lock.writerId, runId: created.runId });
117
+ }
118
+ const runStartedAt = Date.now();
119
+ try {
120
+ const output = await runPhase(phase, input, {
121
+ runDir: created.runDir,
122
+ runId: created.runId,
123
+ writerId: created.lock.writerId,
124
+ phaseIdx: 0,
125
+ });
126
+ // Final lifecycle event — run.complete. The runner doesn't emit this
127
+ // on its own; it's the caller's responsibility (multi-phase pipelines
128
+ // emit it after the LAST phase, single-phase wrappers like this emit
129
+ // after the only phase). Total cost falls back to 0 when the phase
130
+ // doesn't expose a `costUSD` field on its output (read-only verbs
131
+ // don't track cost; scan does).
132
+ const totalCostUSD = extractCostUSD(output);
133
+ appendEvent(created.runDir, {
134
+ event: 'run.complete',
135
+ status: 'success',
136
+ totalCostUSD,
137
+ durationMs: Date.now() - runStartedAt,
138
+ }, { writerId: created.lock.writerId, runId: created.runId });
139
+ // Refresh state.json from the replayed events. The events.ndjson is
140
+ // the source of truth; state.json is a derived snapshot that we MUST
141
+ // rewrite after run.complete so `runs show` / `runs list` reflect the
142
+ // terminal status without needing to replay on every read.
143
+ writeStateSnapshot(created.runDir, replayState(created.runDir));
144
+ return { output, runId: created.runId, runDir: created.runDir };
145
+ }
146
+ catch (err) {
147
+ // Engine-on failure — write run.complete with failed status, refresh
148
+ // state.json, print the legacy banner to stderr, then re-throw so the
149
+ // caller can return its legacy non-zero exit code. (Lock release
150
+ // happens in `finally` regardless of success / failure path.)
151
+ appendEvent(created.runDir, {
152
+ event: 'run.complete',
153
+ status: 'failed',
154
+ totalCostUSD: 0,
155
+ durationMs: Date.now() - runStartedAt,
156
+ }, { writerId: created.lock.writerId, runId: created.runId });
157
+ writeStateSnapshot(created.runDir, replayState(created.runDir));
158
+ const message = err instanceof Error ? err.message : String(err);
159
+ process.stderr.write(`${ANSI_RED}[${phase.name}] engine: phase failed — ${message}${ANSI_RESET}\n`);
160
+ process.stderr.write(`${ANSI_DIM} inspect: claude-autopilot runs show ${created.runId} --events${ANSI_RESET}\n`);
161
+ throw err;
162
+ }
163
+ finally {
164
+ // Best-effort lock release. The lock module's `release()` is
165
+ // idempotent; if the catch path already released (it doesn't, but a
166
+ // future change might), this is a no-op. Wrapping the await in
167
+ // `.catch(() => {})` ensures a release error never masks the original
168
+ // throw — the spec calls this out explicitly.
169
+ await created.lock.release().catch(() => { });
170
+ }
171
+ }
172
+ /** Extract `costUSD` from a phase output if present, else 0. JSON-style
173
+ * duck-typing: we accept any output that exposes a numeric `costUSD`
174
+ * field. Today only `scan` exposes one; the other 7 wrapped verbs
175
+ * return outputs without a cost field, which means `extractCostUSD`
176
+ * returns 0 — byte-for-byte matching the inline `totalCostUSD: 0` they
177
+ * used pre-v6.0.6. */
178
+ function extractCostUSD(output) {
179
+ if (output !== null && typeof output === 'object' && 'costUSD' in output) {
180
+ const v = output.costUSD;
181
+ if (typeof v === 'number' && Number.isFinite(v))
182
+ return v;
183
+ }
184
+ return 0;
185
+ }
186
+ //# sourceMappingURL=run-phase-with-lifecycle.js.map
@@ -0,0 +1,57 @@
1
+ import { type RunLockHandle } from './lock.ts';
2
+ import { type RunIndex, type RunIndexEntry, type RunState } from './types.ts';
3
+ export declare function runsRoot(cwd: string): string;
4
+ export declare function indexPath(cwd: string): string;
5
+ export declare function runDirFor(cwd: string, runId: string): string;
6
+ export interface CreateRunOptions {
7
+ cwd: string;
8
+ /** Phase names in the order they will execute. */
9
+ phases: string[];
10
+ /** Snapshot of the relevant guardrail.config.yaml fields. Free-form. */
11
+ config?: Record<string, unknown>;
12
+ }
13
+ export interface CreateRunResult {
14
+ runId: string;
15
+ runDir: string;
16
+ state: RunState;
17
+ /** Lock handle. Caller MUST `release()` on shutdown. */
18
+ lock: RunLockHandle;
19
+ }
20
+ /** Create a fresh run directory, acquire its advisory lock, write the
21
+ * initial state.json, and emit the `run.start` event.
22
+ *
23
+ * Throws GuardrailError(lock_held) if a stale lock exists for the freshly-
24
+ * generated runId — extremely unlikely (ULIDs are unique) but possible if
25
+ * two parallel invocations on the same OS clock collide on a leftover dir
26
+ * on disk. Caller can simply retry. */
27
+ export declare function createRun(opts: CreateRunOptions): Promise<CreateRunResult>;
28
+ /** Rebuild index.json from each run dir's state.json (or replayed state if
29
+ * the snapshot is missing / corrupt). Newest-first ordering by ULID. */
30
+ export declare function rebuildIndex(cwd: string): RunIndex;
31
+ export interface ListRunsOptions {
32
+ /** Force a rebuild from disk even if index.json is fresh. */
33
+ rebuild?: boolean;
34
+ }
35
+ /** List all runs, newest-first. Lazily rebuilds index.json if missing. */
36
+ export declare function listRuns(cwd: string, opts?: ListRunsOptions): RunIndexEntry[];
37
+ export interface GcRunsOptions {
38
+ /** Delete completed runs older than this many days. Required. */
39
+ olderThanDays: number;
40
+ /** Don't actually delete; just return what would be removed. */
41
+ dryRun?: boolean;
42
+ /** Override "now" for tests. Default Date.now(). */
43
+ now?: number;
44
+ }
45
+ export interface GcRunsResult {
46
+ /** runIds that were (or would be) deleted. */
47
+ deleted: string[];
48
+ /** runIds skipped because they're still active or too young. */
49
+ kept: string[];
50
+ /** runIds skipped for safety reasons (symlink, suspicious path). */
51
+ skippedUnsafe: string[];
52
+ }
53
+ /** Delete completed runs older than N days. Honors the spec's symlink
54
+ * safety: uses lstat so we never traverse a symlink out of the runs/
55
+ * tree. */
56
+ export declare function gcRuns(cwd: string, opts: GcRunsOptions): GcRunsResult;
57
+ //# sourceMappingURL=runs.d.ts.map
@@ -0,0 +1,288 @@
1
+ // src/core/run-state/runs.ts
2
+ //
3
+ // Top-level Run State Engine helpers — createRun, listRuns, gcRuns. These
4
+ // are the entry points the (yet-to-be-built) phase wrapper, CLI, and budget
5
+ // enforcer will call. Phase 1 ships only the data layer; later phases build
6
+ // on top.
7
+ //
8
+ // Spec: docs/specs/v6-run-state-engine.md "State on disk", "Run lifecycle",
9
+ // "Resume command".
10
+ import * as fs from 'node:fs';
11
+ import * as path from 'node:path';
12
+ import { ulid, decodeTime } from "./ulid.js";
13
+ import { acquireRunLock } from "./lock.js";
14
+ import { appendEvent, foldEvents, readEvents, stateToIndexEntry } from "./events.js";
15
+ import { writeStateSnapshot } from "./state.js";
16
+ import { RUN_STATE_SCHEMA_VERSION, } from "./types.js";
17
+ const CACHE_DIR = '.guardrail-cache';
18
+ const RUNS_DIR = 'runs';
19
+ const INDEX_FILE = 'index.json';
20
+ export function runsRoot(cwd) {
21
+ return path.join(cwd, CACHE_DIR, RUNS_DIR);
22
+ }
23
+ export function indexPath(cwd) {
24
+ return path.join(runsRoot(cwd), INDEX_FILE);
25
+ }
26
+ export function runDirFor(cwd, runId) {
27
+ return path.join(runsRoot(cwd), runId);
28
+ }
29
+ /** Create a fresh run directory, acquire its advisory lock, write the
30
+ * initial state.json, and emit the `run.start` event.
31
+ *
32
+ * Throws GuardrailError(lock_held) if a stale lock exists for the freshly-
33
+ * generated runId — extremely unlikely (ULIDs are unique) but possible if
34
+ * two parallel invocations on the same OS clock collide on a leftover dir
35
+ * on disk. Caller can simply retry. */
36
+ export async function createRun(opts) {
37
+ if (!Array.isArray(opts.phases) || opts.phases.length === 0) {
38
+ throw new Error('createRun: phases[] must be a non-empty array');
39
+ }
40
+ const runId = ulid();
41
+ const runDir = runDirFor(opts.cwd, runId);
42
+ fs.mkdirSync(runDir, { recursive: true });
43
+ // Acquire BEFORE first event write so writerId is well-defined.
44
+ const lock = await acquireRunLock(runDir);
45
+ // Seed the state snapshot first (with no events yet) so that even a crash
46
+ // before run.start lands leaves a recoverable artifact.
47
+ const startedAt = new Date(decodeTime(runId)).toISOString();
48
+ const initialState = {
49
+ schema_version: RUN_STATE_SCHEMA_VERSION,
50
+ runId,
51
+ startedAt,
52
+ status: 'pending',
53
+ phases: opts.phases.map((name, idx) => ({
54
+ schema_version: RUN_STATE_SCHEMA_VERSION,
55
+ name,
56
+ index: idx,
57
+ status: 'pending',
58
+ idempotent: false,
59
+ hasSideEffects: false,
60
+ costUSD: 0,
61
+ attempts: 0,
62
+ artifacts: [],
63
+ externalRefs: [],
64
+ })),
65
+ currentPhaseIdx: 0,
66
+ totalCostUSD: 0,
67
+ lastEventSeq: 0,
68
+ writerId: lock.writerId,
69
+ cwd: opts.cwd,
70
+ ...(opts.config !== undefined ? { config: opts.config } : {}),
71
+ };
72
+ writeStateSnapshot(runDir, initialState);
73
+ // Emit run.start. The appender owns the seq counter.
74
+ const startEvent = appendEvent(runDir, {
75
+ event: 'run.start',
76
+ phases: opts.phases,
77
+ ...(opts.config !== undefined ? { config: opts.config } : {}),
78
+ }, { writerId: lock.writerId, runId });
79
+ // Refresh the snapshot to reflect lastEventSeq=1.
80
+ initialState.lastEventSeq = startEvent.seq;
81
+ writeStateSnapshot(runDir, initialState);
82
+ // Refresh the index (best-effort — index is a pure cache).
83
+ try {
84
+ rebuildIndex(opts.cwd);
85
+ }
86
+ catch {
87
+ // Index failure shouldn't block the run.
88
+ }
89
+ return { runId, runDir, state: initialState, lock };
90
+ }
91
+ // ----------------------------------------------------------------------------
92
+ // Listing + indexing.
93
+ // ----------------------------------------------------------------------------
94
+ function readIndex(cwd) {
95
+ const p = indexPath(cwd);
96
+ if (!fs.existsSync(p))
97
+ return null;
98
+ try {
99
+ return JSON.parse(fs.readFileSync(p, 'utf8'));
100
+ }
101
+ catch {
102
+ return null; // treat unreadable index as missing — it's a cache
103
+ }
104
+ }
105
+ function writeIndex(cwd, index) {
106
+ fs.mkdirSync(runsRoot(cwd), { recursive: true });
107
+ fs.writeFileSync(indexPath(cwd), JSON.stringify(index, null, 2), 'utf8');
108
+ }
109
+ /** Rebuild index.json from each run dir's state.json (or replayed state if
110
+ * the snapshot is missing / corrupt). Newest-first ordering by ULID. */
111
+ export function rebuildIndex(cwd) {
112
+ const root = runsRoot(cwd);
113
+ const entries = [];
114
+ if (!fs.existsSync(root)) {
115
+ const empty = { schema_version: RUN_STATE_SCHEMA_VERSION, runs: [] };
116
+ writeIndex(cwd, empty);
117
+ return empty;
118
+ }
119
+ const dirents = fs.readdirSync(root, { withFileTypes: true });
120
+ for (const d of dirents) {
121
+ if (!d.isDirectory())
122
+ continue;
123
+ const runId = d.name;
124
+ const runDir = path.join(root, runId);
125
+ let state;
126
+ let recovered = false;
127
+ try {
128
+ // We don't hold the lock during a list — listing is read-only and
129
+ // races with a concurrent writer are tolerated (we may briefly read
130
+ // a stale snapshot, which is fine). For replay-recovery we DO need
131
+ // a writerId, but only if the snapshot is bad; if so the run isn't
132
+ // healthy anyway, and we use a synthetic writerId so we never
133
+ // mutate the run's events.ndjson during a list operation.
134
+ // Instead of recoverState (which writes events) we just replay
135
+ // in-memory.
136
+ const fromEvents = readEvents(runDir);
137
+ // Build a fresh snapshot if state.json is missing or unreadable.
138
+ // Use the project-internal file paths to avoid pulling readState
139
+ // here just to throw.
140
+ const stateFilePath = path.join(runDir, 'state.json');
141
+ if (fs.existsSync(stateFilePath)) {
142
+ try {
143
+ state = JSON.parse(fs.readFileSync(stateFilePath, 'utf8'));
144
+ }
145
+ catch {
146
+ // fall through to replay
147
+ recovered = true;
148
+ // Replay needs the events; if the events are also corrupt we
149
+ // surface the error via skip.
150
+ state = replayInMemory(runDir, fromEvents.events);
151
+ }
152
+ }
153
+ else {
154
+ recovered = true;
155
+ state = replayInMemory(runDir, fromEvents.events);
156
+ }
157
+ }
158
+ catch {
159
+ // Corrupt run dir — skip from the index entirely. `runs doctor`
160
+ // (Phase 3) will surface these.
161
+ continue;
162
+ }
163
+ entries.push(stateToIndexEntry(state, recovered));
164
+ }
165
+ // ULIDs are sortable; we want NEWEST first → reverse-sort by runId.
166
+ entries.sort((a, b) => (a.runId < b.runId ? 1 : a.runId > b.runId ? -1 : 0));
167
+ const index = { schema_version: RUN_STATE_SCHEMA_VERSION, runs: entries };
168
+ writeIndex(cwd, index);
169
+ return index;
170
+ }
171
+ /** In-memory replay used by rebuildIndex / listRuns — does NOT write to disk
172
+ * or emit events. Lets us pass pre-fetched events so we don't double-read
173
+ * the file. */
174
+ function replayInMemory(runDir, events) {
175
+ return foldEvents(runDir, events);
176
+ }
177
+ /** List all runs, newest-first. Lazily rebuilds index.json if missing. */
178
+ export function listRuns(cwd, opts = {}) {
179
+ if (opts.rebuild)
180
+ return rebuildIndex(cwd).runs;
181
+ const idx = readIndex(cwd);
182
+ if (idx)
183
+ return idx.runs;
184
+ return rebuildIndex(cwd).runs;
185
+ }
186
+ /** Delete completed runs older than N days. Honors the spec's symlink
187
+ * safety: uses lstat so we never traverse a symlink out of the runs/
188
+ * tree. */
189
+ export function gcRuns(cwd, opts) {
190
+ const root = runsRoot(cwd);
191
+ const result = { deleted: [], kept: [], skippedUnsafe: [] };
192
+ if (!fs.existsSync(root))
193
+ return result;
194
+ const cutoff = (opts.now ?? Date.now()) - opts.olderThanDays * 86_400_000;
195
+ const entries = fs.readdirSync(root, { withFileTypes: true });
196
+ for (const d of entries) {
197
+ if (d.name === INDEX_FILE)
198
+ continue;
199
+ const runId = d.name;
200
+ const runDir = path.join(root, runId);
201
+ // Symlinks (whether to dirs or files) are flagged unsafe. Dirent's
202
+ // isDirectory() returns FALSE for a symlink even if the target is a
203
+ // directory, which matches our policy here — we only operate on real
204
+ // dirs that lstat agrees are not links.
205
+ if (d.isSymbolicLink()) {
206
+ result.skippedUnsafe.push(runId);
207
+ continue;
208
+ }
209
+ if (!d.isDirectory())
210
+ continue;
211
+ let lst;
212
+ try {
213
+ lst = fs.lstatSync(runDir);
214
+ }
215
+ catch {
216
+ result.skippedUnsafe.push(runId);
217
+ continue;
218
+ }
219
+ if (!lst.isDirectory() || lst.isSymbolicLink()) {
220
+ result.skippedUnsafe.push(runId);
221
+ continue;
222
+ }
223
+ // Read state to decide. If unreadable, skip — `runs doctor` will deal.
224
+ let state = null;
225
+ try {
226
+ const sp = path.join(runDir, 'state.json');
227
+ if (fs.existsSync(sp)) {
228
+ state = JSON.parse(fs.readFileSync(sp, 'utf8'));
229
+ }
230
+ }
231
+ catch {
232
+ // fall through
233
+ }
234
+ if (!state) {
235
+ // Defensive: try to derive from ULID alone for "old enough" check.
236
+ // If runId isn't a ULID we treat it as suspicious and skip.
237
+ let createdMs;
238
+ try {
239
+ createdMs = decodeTime(runId);
240
+ }
241
+ catch {
242
+ result.skippedUnsafe.push(runId);
243
+ continue;
244
+ }
245
+ if (createdMs >= cutoff) {
246
+ result.kept.push(runId);
247
+ continue;
248
+ }
249
+ // Fall through — eligible for delete.
250
+ }
251
+ else {
252
+ const terminal = state.status === 'success' || state.status === 'failed' || state.status === 'aborted';
253
+ if (!terminal) {
254
+ result.kept.push(runId);
255
+ continue;
256
+ }
257
+ const endMs = state.endedAt ? Date.parse(state.endedAt) : Date.parse(state.startedAt);
258
+ if (Number.isFinite(endMs) && endMs >= cutoff) {
259
+ result.kept.push(runId);
260
+ continue;
261
+ }
262
+ }
263
+ if (opts.dryRun) {
264
+ result.deleted.push(runId);
265
+ continue;
266
+ }
267
+ try {
268
+ // Defense in depth: refuse to recurse out via a symlink hidden inside.
269
+ // fs.rmSync with `force: true, recursive: true` handles dirs but
270
+ // also follows nothing — it doesn't traverse symlinks for deletion
271
+ // boundaries (it deletes the link, not the target).
272
+ fs.rmSync(runDir, { recursive: true, force: true });
273
+ result.deleted.push(runId);
274
+ }
275
+ catch {
276
+ result.skippedUnsafe.push(runId);
277
+ }
278
+ }
279
+ // Refresh the index after a real GC pass.
280
+ if (!opts.dryRun && result.deleted.length > 0) {
281
+ try {
282
+ rebuildIndex(cwd);
283
+ }
284
+ catch { /* index is cache */ }
285
+ }
286
+ return result;
287
+ }
288
+ //# sourceMappingURL=runs.js.map
@@ -0,0 +1,14 @@
1
+ import { type PhaseSnapshot } from './types.ts';
2
+ export declare function phasesDir(runDir: string): string;
3
+ export declare function phaseSnapshotPath(runDir: string, phaseName: string): string;
4
+ /** Write a per-phase snapshot atomically. Identical sequence to
5
+ * state.json:
6
+ * open(tmp, 'w') → write → fsync(fd) → close → rename → fsync(dirfd).
7
+ *
8
+ * Any pre-existing snapshot is left untouched until the rename, so a crash
9
+ * mid-write leaves the previous snapshot intact. */
10
+ export declare function writePhaseSnapshot(runDir: string, snapshot: PhaseSnapshot): void;
11
+ /** Read a per-phase snapshot. Returns null if missing. Throws
12
+ * GuardrailError(corrupted_state) if it's present-but-unparseable. */
13
+ export declare function readPhaseSnapshot(runDir: string, phaseName: string): PhaseSnapshot | null;
14
+ //# sourceMappingURL=snapshot.d.ts.map
@@ -0,0 +1,114 @@
1
+ // src/core/run-state/snapshot.ts
2
+ //
3
+ // Atomic per-phase snapshot writer/reader. Each phase, after run, gets a
4
+ // `phases/<name>.json` artifact mirroring the corresponding entry in
5
+ // state.json. Writes use the same tmp+rename+fsync protocol as state.json so
6
+ // a crash mid-write never leaves a half-baked phase snapshot on disk.
7
+ //
8
+ // Phase 1 left this as a TODO; Phase 2 fills it in to back the lifecycle
9
+ // wrapper (`runPhase`).
10
+ //
11
+ // Spec: docs/specs/v6-run-state-engine.md "State on disk" — `phases/<name>.json`.
12
+ import * as fs from 'node:fs';
13
+ import * as path from 'node:path';
14
+ import { GuardrailError } from "../errors.js";
15
+ const PHASES_DIR = 'phases';
16
+ export function phasesDir(runDir) {
17
+ return path.join(runDir, PHASES_DIR);
18
+ }
19
+ export function phaseSnapshotPath(runDir, phaseName) {
20
+ return path.join(phasesDir(runDir), `${sanitizePhaseFilename(phaseName)}.json`);
21
+ }
22
+ /** Reject filename characters that would escape `phases/`. Phase names are
23
+ * caller-supplied strings; we bound them to a safe charset rather than
24
+ * letting `..` / path separators sneak in.
25
+ *
26
+ * Allowed: ASCII alphanumerics, dash, underscore, dot. Anything else is
27
+ * rejected with a typed error so callers can correct the call-site rather
28
+ * than silently producing a write to `../somewhere`. */
29
+ function sanitizePhaseFilename(phaseName) {
30
+ if (!phaseName || typeof phaseName !== 'string') {
31
+ throw new GuardrailError(`phase snapshot: name must be a non-empty string`, { code: 'invalid_config', provider: 'run-state', details: { phaseName } });
32
+ }
33
+ if (!/^[A-Za-z0-9._-]+$/.test(phaseName)) {
34
+ throw new GuardrailError(`phase snapshot: name "${phaseName}" contains unsupported characters`, { code: 'invalid_config', provider: 'run-state', details: { phaseName } });
35
+ }
36
+ return phaseName;
37
+ }
38
+ /** Write a per-phase snapshot atomically. Identical sequence to
39
+ * state.json:
40
+ * open(tmp, 'w') → write → fsync(fd) → close → rename → fsync(dirfd).
41
+ *
42
+ * Any pre-existing snapshot is left untouched until the rename, so a crash
43
+ * mid-write leaves the previous snapshot intact. */
44
+ export function writePhaseSnapshot(runDir, snapshot) {
45
+ const dir = phasesDir(runDir);
46
+ fs.mkdirSync(dir, { recursive: true });
47
+ const target = phaseSnapshotPath(runDir, snapshot.name);
48
+ const tmp = `${target}.tmp`;
49
+ const data = JSON.stringify(snapshot, null, 2);
50
+ const fd = fs.openSync(tmp, 'w');
51
+ let wroteOk = false;
52
+ try {
53
+ fs.writeSync(fd, data);
54
+ fs.fsyncSync(fd);
55
+ wroteOk = true;
56
+ }
57
+ finally {
58
+ fs.closeSync(fd);
59
+ if (!wroteOk) {
60
+ try {
61
+ fs.unlinkSync(tmp);
62
+ }
63
+ catch { /* ignore */ }
64
+ }
65
+ }
66
+ fs.renameSync(tmp, target);
67
+ // Best-effort dir fsync for rename durability. Same EISDIR/EPERM/ENOTSUP
68
+ // tolerance as state.ts (tmpfs / SMB / Windows quirks).
69
+ try {
70
+ const dirFd = fs.openSync(dir, 'r');
71
+ try {
72
+ fs.fsyncSync(dirFd);
73
+ }
74
+ finally {
75
+ fs.closeSync(dirFd);
76
+ }
77
+ }
78
+ catch (err) {
79
+ const code = err.code;
80
+ if (code !== 'EISDIR' && code !== 'EPERM' && code !== 'ENOTSUP') {
81
+ throw new GuardrailError(`phase snapshot: dir fsync failed: ${err.message}`, {
82
+ code: 'corrupted_state',
83
+ provider: 'run-state',
84
+ details: { runDir, phaseName: snapshot.name, errno: code },
85
+ });
86
+ }
87
+ }
88
+ }
89
+ /** Read a per-phase snapshot. Returns null if missing. Throws
90
+ * GuardrailError(corrupted_state) if it's present-but-unparseable. */
91
+ export function readPhaseSnapshot(runDir, phaseName) {
92
+ const p = phaseSnapshotPath(runDir, phaseName);
93
+ if (!fs.existsSync(p))
94
+ return null;
95
+ const raw = fs.readFileSync(p, 'utf8');
96
+ if (!raw) {
97
+ throw new GuardrailError(`phase snapshot: empty file ${p}`, {
98
+ code: 'corrupted_state',
99
+ provider: 'run-state',
100
+ details: { runDir, phaseName },
101
+ });
102
+ }
103
+ try {
104
+ return JSON.parse(raw);
105
+ }
106
+ catch (err) {
107
+ throw new GuardrailError(`phase snapshot: corrupt JSON: ${err.message}`, {
108
+ code: 'corrupted_state',
109
+ provider: 'run-state',
110
+ details: { runDir, phaseName, error: err.message },
111
+ });
112
+ }
113
+ }
114
+ //# sourceMappingURL=snapshot.js.map