@delegance/claude-autopilot 5.5.2 → 7.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (150) hide show
  1. package/CHANGELOG.md +1776 -6
  2. package/README.md +65 -1
  3. package/bin/_launcher.js +38 -23
  4. package/dist/src/adapters/council/openai.js +12 -6
  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/index.d.ts +2 -0
  10. package/dist/src/adapters/deploy/index.js +33 -0
  11. package/dist/src/adapters/deploy/render.d.ts +181 -0
  12. package/dist/src/adapters/deploy/render.js +550 -0
  13. package/dist/src/adapters/deploy/types.d.ts +67 -3
  14. package/dist/src/adapters/deploy/vercel.d.ts +17 -1
  15. package/dist/src/adapters/deploy/vercel.js +29 -49
  16. package/dist/src/adapters/pricing.d.ts +36 -0
  17. package/dist/src/adapters/pricing.js +40 -0
  18. package/dist/src/adapters/review-engine/codex.js +10 -7
  19. package/dist/src/cli/autopilot.d.ts +75 -0
  20. package/dist/src/cli/autopilot.js +750 -0
  21. package/dist/src/cli/brainstorm.d.ts +23 -0
  22. package/dist/src/cli/brainstorm.js +131 -0
  23. package/dist/src/cli/costs.d.ts +15 -1
  24. package/dist/src/cli/costs.js +99 -10
  25. package/dist/src/cli/dashboard/index.d.ts +5 -0
  26. package/dist/src/cli/dashboard/index.js +49 -0
  27. package/dist/src/cli/dashboard/login.d.ts +22 -0
  28. package/dist/src/cli/dashboard/login.js +260 -0
  29. package/dist/src/cli/dashboard/logout.d.ts +12 -0
  30. package/dist/src/cli/dashboard/logout.js +45 -0
  31. package/dist/src/cli/dashboard/status.d.ts +30 -0
  32. package/dist/src/cli/dashboard/status.js +65 -0
  33. package/dist/src/cli/dashboard/upload.d.ts +16 -0
  34. package/dist/src/cli/dashboard/upload.js +48 -0
  35. package/dist/src/cli/deploy.d.ts +3 -3
  36. package/dist/src/cli/deploy.js +34 -9
  37. package/dist/src/cli/engine-flag-deprecation.d.ts +14 -0
  38. package/dist/src/cli/engine-flag-deprecation.js +20 -0
  39. package/dist/src/cli/fix.d.ts +18 -0
  40. package/dist/src/cli/fix.js +105 -11
  41. package/dist/src/cli/help-text.d.ts +52 -0
  42. package/dist/src/cli/help-text.js +416 -0
  43. package/dist/src/cli/implement.d.ts +91 -0
  44. package/dist/src/cli/implement.js +196 -0
  45. package/dist/src/cli/index.d.ts +2 -1
  46. package/dist/src/cli/index.js +774 -245
  47. package/dist/src/cli/json-envelope.d.ts +187 -0
  48. package/dist/src/cli/json-envelope.js +270 -0
  49. package/dist/src/cli/json-mode.d.ts +33 -0
  50. package/dist/src/cli/json-mode.js +201 -0
  51. package/dist/src/cli/migrate.d.ts +111 -0
  52. package/dist/src/cli/migrate.js +305 -0
  53. package/dist/src/cli/plan.d.ts +81 -0
  54. package/dist/src/cli/plan.js +149 -0
  55. package/dist/src/cli/pr.d.ts +106 -0
  56. package/dist/src/cli/pr.js +191 -19
  57. package/dist/src/cli/preflight.js +26 -0
  58. package/dist/src/cli/review.d.ts +27 -0
  59. package/dist/src/cli/review.js +126 -0
  60. package/dist/src/cli/runs-watch-renderer.d.ts +45 -0
  61. package/dist/src/cli/runs-watch-renderer.js +275 -0
  62. package/dist/src/cli/runs-watch.d.ts +41 -0
  63. package/dist/src/cli/runs-watch.js +395 -0
  64. package/dist/src/cli/runs.d.ts +122 -0
  65. package/dist/src/cli/runs.js +902 -0
  66. package/dist/src/cli/scaffold.d.ts +39 -0
  67. package/dist/src/cli/scaffold.js +287 -0
  68. package/dist/src/cli/scan.d.ts +93 -0
  69. package/dist/src/cli/scan.js +166 -40
  70. package/dist/src/cli/setup.d.ts +30 -0
  71. package/dist/src/cli/setup.js +137 -0
  72. package/dist/src/cli/spec.d.ts +66 -0
  73. package/dist/src/cli/spec.js +132 -0
  74. package/dist/src/cli/validate.d.ts +29 -0
  75. package/dist/src/cli/validate.js +131 -0
  76. package/dist/src/core/config/schema.d.ts +9 -0
  77. package/dist/src/core/config/schema.js +7 -0
  78. package/dist/src/core/config/types.d.ts +11 -0
  79. package/dist/src/core/council/runner.d.ts +10 -1
  80. package/dist/src/core/council/runner.js +25 -3
  81. package/dist/src/core/council/types.d.ts +7 -0
  82. package/dist/src/core/errors.d.ts +1 -1
  83. package/dist/src/core/errors.js +11 -0
  84. package/dist/src/core/logging/redaction.d.ts +13 -0
  85. package/dist/src/core/logging/redaction.js +20 -0
  86. package/dist/src/core/migrate/schema-validator.js +15 -1
  87. package/dist/src/core/phases/static-rules.d.ts +5 -1
  88. package/dist/src/core/phases/static-rules.js +2 -5
  89. package/dist/src/core/run-state/budget.d.ts +88 -0
  90. package/dist/src/core/run-state/budget.js +141 -0
  91. package/dist/src/core/run-state/cli-internal.d.ts +21 -0
  92. package/dist/src/core/run-state/cli-internal.js +174 -0
  93. package/dist/src/core/run-state/events.d.ts +59 -0
  94. package/dist/src/core/run-state/events.js +512 -0
  95. package/dist/src/core/run-state/lock.d.ts +61 -0
  96. package/dist/src/core/run-state/lock.js +206 -0
  97. package/dist/src/core/run-state/phase-context.d.ts +60 -0
  98. package/dist/src/core/run-state/phase-context.js +108 -0
  99. package/dist/src/core/run-state/phase-registry.d.ts +137 -0
  100. package/dist/src/core/run-state/phase-registry.js +162 -0
  101. package/dist/src/core/run-state/phase-runner.d.ts +80 -0
  102. package/dist/src/core/run-state/phase-runner.js +447 -0
  103. package/dist/src/core/run-state/provider-readback.d.ts +130 -0
  104. package/dist/src/core/run-state/provider-readback.js +426 -0
  105. package/dist/src/core/run-state/replay-decision.d.ts +69 -0
  106. package/dist/src/core/run-state/replay-decision.js +144 -0
  107. package/dist/src/core/run-state/resolve-engine.d.ts +45 -0
  108. package/dist/src/core/run-state/resolve-engine.js +74 -0
  109. package/dist/src/core/run-state/resume-preflight.d.ts +66 -0
  110. package/dist/src/core/run-state/resume-preflight.js +116 -0
  111. package/dist/src/core/run-state/run-phase-with-lifecycle.d.ts +69 -0
  112. package/dist/src/core/run-state/run-phase-with-lifecycle.js +193 -0
  113. package/dist/src/core/run-state/runs.d.ts +57 -0
  114. package/dist/src/core/run-state/runs.js +288 -0
  115. package/dist/src/core/run-state/snapshot.d.ts +14 -0
  116. package/dist/src/core/run-state/snapshot.js +114 -0
  117. package/dist/src/core/run-state/state.d.ts +40 -0
  118. package/dist/src/core/run-state/state.js +164 -0
  119. package/dist/src/core/run-state/types.d.ts +284 -0
  120. package/dist/src/core/run-state/types.js +19 -0
  121. package/dist/src/core/run-state/ulid.d.ts +11 -0
  122. package/dist/src/core/run-state/ulid.js +95 -0
  123. package/dist/src/core/schema-alignment/extractor/index.d.ts +1 -1
  124. package/dist/src/core/schema-alignment/extractor/index.js +2 -2
  125. package/dist/src/core/schema-alignment/extractor/prisma.d.ts +13 -1
  126. package/dist/src/core/schema-alignment/extractor/prisma.js +65 -10
  127. package/dist/src/core/schema-alignment/git-history.d.ts +19 -0
  128. package/dist/src/core/schema-alignment/git-history.js +53 -0
  129. package/dist/src/core/static-rules/rules/brand-tokens.js +2 -2
  130. package/dist/src/core/static-rules/rules/schema-alignment.js +14 -4
  131. package/dist/src/dashboard/auto-upload.d.ts +26 -0
  132. package/dist/src/dashboard/auto-upload.js +107 -0
  133. package/dist/src/dashboard/config.d.ts +22 -0
  134. package/dist/src/dashboard/config.js +109 -0
  135. package/dist/src/dashboard/upload/canonical.d.ts +3 -0
  136. package/dist/src/dashboard/upload/canonical.js +16 -0
  137. package/dist/src/dashboard/upload/chain.d.ts +9 -0
  138. package/dist/src/dashboard/upload/chain.js +27 -0
  139. package/dist/src/dashboard/upload/snapshot.d.ts +23 -0
  140. package/dist/src/dashboard/upload/snapshot.js +66 -0
  141. package/dist/src/dashboard/upload/uploader.d.ts +54 -0
  142. package/dist/src/dashboard/upload/uploader.js +330 -0
  143. package/package.json +19 -3
  144. package/scripts/autoregress.ts +1 -1
  145. package/scripts/test-runner.mjs +4 -0
  146. package/skills/claude-autopilot.md +1 -1
  147. package/skills/make-interfaces-feel-better/SKILL.md +104 -0
  148. package/skills/simplify-ui/SKILL.md +103 -0
  149. package/skills/ui/SKILL.md +117 -0
  150. package/skills/ui-ux-pro-max/SKILL.md +90 -0
@@ -0,0 +1,80 @@
1
+ import { type BudgetCheck, type BudgetConfig } from './budget.ts';
2
+ import { type PhaseContext } from './phase-context.ts';
3
+ import { type ReadbackResult } from './provider-readback.ts';
4
+ import { type ExternalRef, type WriterId } from './types.ts';
5
+ /** What `RunPhase.onResume` receives when a previous attempt of the same
6
+ * phaseIdx exists. Phase 6 will fully wire this; in Phase 2 we expose the
7
+ * shape so callers can author against it without a later breaking change. */
8
+ export interface PhaseResumeContext {
9
+ runDir: string;
10
+ runId: string;
11
+ phaseIdx: number;
12
+ /** All externalRefs recorded for this phase across prior attempts. */
13
+ externalRefs: ExternalRef[];
14
+ /** How many `phase.start` events have been observed for this phaseIdx
15
+ * (i.e. the attempt count of the prior run). */
16
+ attempts: number;
17
+ /** Whether the previous attempt was a phase.success (was the phase already
18
+ * done before the current resume began?). */
19
+ succeeded: boolean;
20
+ }
21
+ /** The phase contract — the only object an existing pipeline needs to
22
+ * implement to be run by the engine. Existing phases are wrapped, NOT
23
+ * rewritten; in Phase 2 we ship the wrapper but no actual phase consumes
24
+ * it yet. */
25
+ export interface RunPhase<I = unknown, O = unknown> {
26
+ readonly name: string;
27
+ readonly idempotent: boolean;
28
+ readonly hasSideEffects: boolean;
29
+ estimateCost?(input: I): {
30
+ lowUSD: number;
31
+ highUSD: number;
32
+ };
33
+ run(input: I, ctx: PhaseContext): Promise<O>;
34
+ /** Called when resuming after a previous failure / completion. Decides
35
+ * whether to skip, retry, abort, or bubble to a human. Default behavior
36
+ * (when this method is absent) is encoded in `runPhase` itself: idempotent
37
+ * phases retry, side-effecting phases require `--force-replay`. */
38
+ onResume?(prev: PhaseResumeContext): Promise<'skip' | 'retry' | 'abort' | 'needs-human'>;
39
+ }
40
+ /** What the caller passes in. We require runDir/runId/writerId to be already
41
+ * established (the run-creator already did this). */
42
+ export interface ParentRunContext {
43
+ runDir: string;
44
+ runId: string;
45
+ writerId: WriterId;
46
+ /** Index of this phase within the run's `phases[]`. */
47
+ phaseIdx: number;
48
+ /** When true, override the side-effects gate even if a prior success
49
+ * exists. Records a `run.warning` event noting the override. */
50
+ forceReplay?: boolean;
51
+ /** Phase 4 — optional budget enforcement config. When omitted the
52
+ * runner is back-compat: no `budget.check` event, no preflight, no
53
+ * rejection. When present, the runner consults `checkPhaseBudget`
54
+ * BEFORE emitting `phase.start` and may throw `budget_exceeded`. */
55
+ budget?: BudgetConfig;
56
+ /** When true, a `pause` budget decision becomes `hard-fail` instead of
57
+ * prompting the user. Callers in CI / `--json` mode MUST set this.
58
+ * Default: false (interactive). */
59
+ nonInteractive?: boolean;
60
+ /** Override the interactive confirm prompt. Returning `true` proceeds,
61
+ * `false` rejects. Mainly a test seam; the default uses readline. */
62
+ confirmBudgetPause?: (check: BudgetCheck) => Promise<boolean>;
63
+ /** Phase 6 — override the readback layer. Defaults to `verifyRefs` from
64
+ * `provider-readback.ts`, which uses the registered providers. Tests
65
+ * inject a stub to avoid hitting `gh` / network. */
66
+ verifyRefs?: (refs: ReadonlyArray<ExternalRef>) => Promise<ReadbackResult[]>;
67
+ }
68
+ export type { PhaseContext } from './phase-context.ts';
69
+ /** Run a single phase with full lifecycle instrumentation.
70
+ *
71
+ * Emits, in order:
72
+ * phase.start — always (unless idempotent short-circuit fires first)
73
+ * phase.cost — zero or more, emitted by the phase via ctx.emitCost
74
+ * phase.externalRef — zero or more, via ctx.emitExternalRef
75
+ * phase.success | phase.failed — exactly one
76
+ *
77
+ * Writes phases/<name>.json after either terminal event so a crash between
78
+ * the event and the snapshot is recoverable from events.ndjson. */
79
+ export declare function runPhase<I, O>(phase: RunPhase<I, O>, input: I, parentCtx: ParentRunContext): Promise<O>;
80
+ //# sourceMappingURL=phase-runner.d.ts.map
@@ -0,0 +1,447 @@
1
+ // src/core/run-state/phase-runner.ts
2
+ //
3
+ // v6 Phase 2 — phase wrapper / lifecycle layer.
4
+ //
5
+ // `runPhase` is the orchestrator that wraps a single `RunPhase` invocation:
6
+ //
7
+ // 1. emit phase.start (with attempt counter + idempotent/hasSideEffects
8
+ // flags)
9
+ // 2. call phase.run(input, ctx) — the user's phase body
10
+ // 3. on success → emit phase.success + write phases/<name>.json snapshot
11
+ // 4. on throw → emit phase.failed + write a failed snapshot + rethrow
12
+ //
13
+ // Idempotency / side-effect gating:
14
+ //
15
+ // - If a prior phase.success exists for this (runDir, phaseIdx) AND
16
+ // `phase.idempotent === true`, the runner short-circuits with a
17
+ // `phase.skipped` event-shaped recording (we use the existing
18
+ // phase.success replay-equivalence — a one-shot phase.success is OK
19
+ // because the snapshot will be rewritten with attempts++ and a
20
+ // "skipped"-flavored note in `meta`, plus we emit a `run.warning`
21
+ // with reason `idempotent-replay` so observers can attribute the
22
+ // short-circuit). See "skipped variant" below for the exact event.
23
+ // - If a prior phase.success exists AND `phase.hasSideEffects === true`,
24
+ // the runner refuses without `--force-replay`: it throws GuardrailError
25
+ // `needs_human` carrying the prior externalRefs in `details` so a CI /
26
+ // human consumer can resolve.
27
+ //
28
+ // What this file deliberately does NOT do (Phase 4+ work):
29
+ //
30
+ // - Budget enforcement. `estimateCost` is part of the interface but the
31
+ // policy check lives in a future budget enforcer.
32
+ // - Provider read-back ("is PR #123 still open?"). Phase 6 wires `onResume`
33
+ // to consult externalRefs + read back; Phase 2 just records refs.
34
+ // - Locking. `runPhase` does NOT acquire the per-run advisory lock — the
35
+ // caller (createRun / future resume verb) holds it for the lifetime of
36
+ // the run. We just need a writerId to stamp events; we accept it from
37
+ // parentCtx.
38
+ //
39
+ // Spec: docs/specs/v6-run-state-engine.md "Phase contract", "Run lifecycle",
40
+ // "Idempotency rules + external operation ledger".
41
+ import * as readline from 'node:readline';
42
+ import { GuardrailError } from "../errors.js";
43
+ import { checkPhaseBudget } from "./budget.js";
44
+ import { appendEvent, readEvents } from "./events.js";
45
+ import { buildPhaseContext, collectExternalRefs, countPhaseAttempts, countPhaseSuccesses, sumPhaseCost, } from "./phase-context.js";
46
+ import { decideReplay, } from "./replay-decision.js";
47
+ import { verifyRefs as defaultVerifyRefs, } from "./provider-readback.js";
48
+ import { readPhaseSnapshot, writePhaseSnapshot } from "./snapshot.js";
49
+ import { RUN_STATE_SCHEMA_VERSION, } from "./types.js";
50
+ // ----------------------------------------------------------------------------
51
+ // runPhase — the orchestrator
52
+ // ----------------------------------------------------------------------------
53
+ /** Run a single phase with full lifecycle instrumentation.
54
+ *
55
+ * Emits, in order:
56
+ * phase.start — always (unless idempotent short-circuit fires first)
57
+ * phase.cost — zero or more, emitted by the phase via ctx.emitCost
58
+ * phase.externalRef — zero or more, via ctx.emitExternalRef
59
+ * phase.success | phase.failed — exactly one
60
+ *
61
+ * Writes phases/<name>.json after either terminal event so a crash between
62
+ * the event and the snapshot is recoverable from events.ndjson. */
63
+ export async function runPhase(phase, input, parentCtx) {
64
+ const { runDir, runId, writerId, phaseIdx, forceReplay, budget, nonInteractive, confirmBudgetPause, verifyRefs, } = parentCtx;
65
+ // -- Idempotency / side-effect gating (Phase 6) ------------------------
66
+ // We replay events.ndjson once up-front to detect prior outcomes for this
67
+ // phaseIdx. Cheap — Phase 1 already reads the whole file for replayState.
68
+ const prior = readEvents(runDir);
69
+ const priorSuccessCount = countPhaseSuccesses(prior.events, phaseIdx);
70
+ const priorAttemptCount = countPhaseAttempts(prior.events, phaseIdx);
71
+ const priorRefs = collectExternalRefs(prior.events, phaseIdx);
72
+ if (priorSuccessCount > 0) {
73
+ // Run readbacks ONLY when we'd actually need them (side-effect phases
74
+ // with refs). Idempotent / no-side-effect / no-refs branches don't
75
+ // need a network call to decide.
76
+ let readbacks = [];
77
+ if (phase.hasSideEffects && !phase.idempotent && priorRefs.length > 0 && !forceReplay) {
78
+ const verifier = verifyRefs ?? defaultVerifyRefs;
79
+ try {
80
+ readbacks = await verifier(priorRefs);
81
+ }
82
+ catch {
83
+ // Defense in depth — verifyRefs is supposed to fail-closed per ref,
84
+ // but if the wrapper itself throws we collapse all refs to unknown.
85
+ readbacks = priorRefs.map(r => ({
86
+ refKind: r.kind,
87
+ refId: r.id,
88
+ existsOnPlatform: false,
89
+ currentState: 'unknown',
90
+ }));
91
+ }
92
+ }
93
+ const decision = decideReplay({
94
+ phaseName: phase.name,
95
+ hasPriorSuccess: true,
96
+ priorAttempts: priorAttemptCount,
97
+ idempotent: phase.idempotent,
98
+ hasSideEffects: phase.hasSideEffects,
99
+ externalRefs: priorRefs,
100
+ readbacks,
101
+ forceReplay: forceReplay === true,
102
+ });
103
+ if (decision.decision === 'skip-already-applied') {
104
+ return handleSkipAlreadyApplied({
105
+ decision,
106
+ phase,
107
+ phaseIdx,
108
+ priorEvents: prior.events,
109
+ priorAttemptCount,
110
+ priorRefs,
111
+ runDir,
112
+ runId,
113
+ writerId,
114
+ });
115
+ }
116
+ if (decision.decision === 'needs-human') {
117
+ appendEvent(runDir, {
118
+ event: 'phase.needs-human',
119
+ phase: phase.name,
120
+ phaseIdx,
121
+ reason: decision.reason,
122
+ nextActions: [
123
+ `Inspect prior externalRefs for phase ${phase.name}.`,
124
+ `Re-run with --force-replay if you accept the risk of duplicate side effects.`,
125
+ ],
126
+ }, { writerId, runId });
127
+ throw new GuardrailError(`phase ${phase.name} previously succeeded; ${decision.reason}`, {
128
+ code: 'superseded',
129
+ provider: 'run-state',
130
+ details: {
131
+ runDir,
132
+ phaseIdx,
133
+ priorRefs,
134
+ readbacks: decision.readbacksConsulted,
135
+ reason: 'side-effecting-replay-needs-human',
136
+ },
137
+ });
138
+ }
139
+ if (decision.decision === 'abort') {
140
+ throw new GuardrailError(`phase ${phase.name} aborted by replay decision: ${decision.reason}`, {
141
+ code: 'user_input',
142
+ provider: 'run-state',
143
+ details: { runDir, phaseIdx, priorRefs, reason: 'replay-decision-abort' },
144
+ });
145
+ }
146
+ // decision.decision === 'retry' — continue. If forceReplay drove this,
147
+ // record an explicit replay.override event so the durable log shows the
148
+ // override happened (per spec).
149
+ if (forceReplay === true) {
150
+ appendEvent(runDir, {
151
+ event: 'replay.override',
152
+ phase: phase.name,
153
+ phaseIdx,
154
+ reason: decision.reason,
155
+ refsConsulted: priorRefs,
156
+ }, { writerId, runId });
157
+ }
158
+ }
159
+ // -- Budget preflight (Phase 4) ----------------------------------------
160
+ // Runs AFTER idempotency gating (we don't gate replays we're already
161
+ // going to skip) and BEFORE phase.start (a rejection means the phase
162
+ // never started — no phase.start, no phase.failed; the runner throws
163
+ // GuardrailError budget_exceeded so the caller sees a typed failure
164
+ // and the run can be marked aborted/paused at the orchestrator level).
165
+ if (budget) {
166
+ const actualSoFarUSD = sumRunCost(prior.events);
167
+ const estimate = phase.estimateCost ? phase.estimateCost(input) : null;
168
+ const check = checkPhaseBudget({
169
+ budget,
170
+ phaseName: phase.name,
171
+ phaseIdx,
172
+ estimatedCost: estimate,
173
+ actualSoFarUSD,
174
+ nonInteractive: nonInteractive === true,
175
+ });
176
+ appendEvent(runDir, {
177
+ event: 'budget.check',
178
+ phase: phase.name,
179
+ phaseIdx,
180
+ decision: check.decision,
181
+ estimatedHigh: check.estimatedHigh,
182
+ actualSoFar: check.actualSoFar,
183
+ reserveApplied: check.reserveApplied,
184
+ capRemaining: check.capRemaining,
185
+ reason: check.reason,
186
+ scope: check.scope,
187
+ }, { writerId, runId });
188
+ if (check.decision === 'hard-fail') {
189
+ throw new GuardrailError(`phase ${phase.name} blocked by budget: ${check.reason}`, {
190
+ code: 'budget_exceeded',
191
+ provider: 'run-state',
192
+ details: {
193
+ runDir,
194
+ phaseIdx,
195
+ check,
196
+ },
197
+ });
198
+ }
199
+ if (check.decision === 'pause') {
200
+ const confirm = confirmBudgetPause ?? defaultConfirmBudgetPause;
201
+ const proceed = await confirm(check);
202
+ if (!proceed) {
203
+ throw new GuardrailError(`phase ${phase.name} blocked by budget (user denied resume): ${check.reason}`, {
204
+ code: 'budget_exceeded',
205
+ provider: 'run-state',
206
+ details: {
207
+ runDir,
208
+ phaseIdx,
209
+ check,
210
+ userDenied: true,
211
+ },
212
+ });
213
+ }
214
+ }
215
+ }
216
+ // -- Phase start --------------------------------------------------------
217
+ const attempt = priorAttemptCount + 1;
218
+ const startedAtMs = Date.now();
219
+ appendEvent(runDir, {
220
+ event: 'phase.start',
221
+ phase: phase.name,
222
+ phaseIdx,
223
+ idempotent: phase.idempotent,
224
+ hasSideEffects: phase.hasSideEffects,
225
+ attempt,
226
+ }, { writerId, runId });
227
+ // Build the per-phase context. `subPhase` is wired below.
228
+ const ctx = buildPhaseContext({
229
+ runDir,
230
+ runId,
231
+ phaseName: phase.name,
232
+ phaseIdx,
233
+ writerId,
234
+ subPhase: makeSubPhaseFactory({ runDir, runId, writerId, parentPhaseIdx: phaseIdx }),
235
+ });
236
+ // -- Execute ------------------------------------------------------------
237
+ let output;
238
+ try {
239
+ output = await phase.run(input, ctx);
240
+ }
241
+ catch (err) {
242
+ const durationMs = Date.now() - startedAtMs;
243
+ const message = err instanceof Error ? err.message : String(err);
244
+ const errorCode = err instanceof GuardrailError ? err.code : undefined;
245
+ appendEvent(runDir, {
246
+ event: 'phase.failed',
247
+ phase: phase.name,
248
+ phaseIdx,
249
+ durationMs,
250
+ error: message,
251
+ ...(errorCode !== undefined ? { errorCode } : {}),
252
+ }, { writerId, runId });
253
+ // Re-read events to capture costs / refs the phase emitted before throw.
254
+ const after = readEvents(runDir);
255
+ const failedSnapshot = {
256
+ schema_version: RUN_STATE_SCHEMA_VERSION,
257
+ name: phase.name,
258
+ index: phaseIdx,
259
+ status: 'failed',
260
+ idempotent: phase.idempotent,
261
+ hasSideEffects: phase.hasSideEffects,
262
+ startedAt: new Date(startedAtMs).toISOString(),
263
+ endedAt: new Date().toISOString(),
264
+ durationMs,
265
+ costUSD: sumPhaseCost(after.events, phaseIdx),
266
+ attempts: attempt,
267
+ lastError: message,
268
+ artifacts: [],
269
+ externalRefs: collectExternalRefs(after.events, phaseIdx),
270
+ };
271
+ writePhaseSnapshot(runDir, failedSnapshot);
272
+ throw err;
273
+ }
274
+ // -- Success ------------------------------------------------------------
275
+ const durationMs = Date.now() - startedAtMs;
276
+ appendEvent(runDir, {
277
+ event: 'phase.success',
278
+ phase: phase.name,
279
+ phaseIdx,
280
+ durationMs,
281
+ artifacts: [],
282
+ }, { writerId, runId });
283
+ // Re-read to capture costs / refs the phase emitted during run().
284
+ const after = readEvents(runDir);
285
+ // Phase 6 — persist the phase output so a future skip-already-applied
286
+ // can return it without re-execution. Only persist values that JSON
287
+ // round-trip cleanly; if the phase returned something non-serializable
288
+ // (a function, a class instance with circular refs, a Buffer, …) we
289
+ // store undefined and rely on the phase being idempotent enough that a
290
+ // future caller doesn't actually need the prior value.
291
+ const persistedResult = jsonRoundTrip(output);
292
+ const successSnapshot = {
293
+ schema_version: RUN_STATE_SCHEMA_VERSION,
294
+ name: phase.name,
295
+ index: phaseIdx,
296
+ status: 'succeeded',
297
+ idempotent: phase.idempotent,
298
+ hasSideEffects: phase.hasSideEffects,
299
+ startedAt: new Date(startedAtMs).toISOString(),
300
+ endedAt: new Date().toISOString(),
301
+ durationMs,
302
+ costUSD: sumPhaseCost(after.events, phaseIdx),
303
+ attempts: attempt,
304
+ artifacts: [],
305
+ externalRefs: collectExternalRefs(after.events, phaseIdx),
306
+ ...(persistedResult !== undefined ? { result: persistedResult } : {}),
307
+ };
308
+ writePhaseSnapshot(runDir, successSnapshot);
309
+ return output;
310
+ }
311
+ /** Phase 6 — handle a `skip-already-applied` decision. Surfaces the prior
312
+ * result from the persisted snapshot if available; otherwise records the
313
+ * skip and rewrites the snapshot with `meta.skipped=true` then throws a
314
+ * typed `superseded` so the caller can react (matches the Phase 2
315
+ * contract for idempotent short-circuits). */
316
+ function handleSkipAlreadyApplied(opts) {
317
+ const { decision, phase, phaseIdx, priorEvents, priorAttemptCount, priorRefs, runDir, runId, writerId, } = opts;
318
+ appendEvent(runDir, {
319
+ event: 'run.warning',
320
+ message: `phase ${phase.name} short-circuited: ${decision.reason}`,
321
+ details: {
322
+ phase: phase.name,
323
+ phaseIdx,
324
+ reason: 'skip-already-applied',
325
+ decision: decision.decision,
326
+ readbacks: decision.readbacksConsulted,
327
+ },
328
+ }, { writerId, runId });
329
+ const priorSnapshot = readPhaseSnapshot(runDir, phase.name);
330
+ const persistedResult = priorSnapshot?.result;
331
+ const refreshed = {
332
+ schema_version: RUN_STATE_SCHEMA_VERSION,
333
+ name: phase.name,
334
+ index: phaseIdx,
335
+ status: 'succeeded',
336
+ idempotent: phase.idempotent,
337
+ hasSideEffects: phase.hasSideEffects,
338
+ costUSD: sumPhaseCost(priorEvents, phaseIdx),
339
+ attempts: priorAttemptCount, // unchanged — we did NOT start
340
+ artifacts: priorSnapshot?.artifacts ?? [],
341
+ externalRefs: priorRefs.length > 0 ? priorRefs : (priorSnapshot?.externalRefs ?? []),
342
+ meta: { skipped: true, reason: 'skip-already-applied', decisionReason: decision.reason },
343
+ ...(persistedResult !== undefined ? { result: persistedResult } : {}),
344
+ };
345
+ writePhaseSnapshot(runDir, refreshed);
346
+ // If we have a prior result, return it. Otherwise throw `superseded` so
347
+ // the caller knows to consult the snapshot / onResume hook (matches the
348
+ // Phase 2 contract for idempotent short-circuits without a stored value).
349
+ if (persistedResult !== undefined) {
350
+ return persistedResult;
351
+ }
352
+ throw new GuardrailError(`phase ${phase.name} was already completed (skip-already-applied) but ` +
353
+ `no prior result is persisted — the caller should consult phases/${phase.name}.json or onResume.`, {
354
+ code: 'superseded',
355
+ provider: 'run-state',
356
+ details: {
357
+ runDir,
358
+ phaseIdx,
359
+ priorRefs,
360
+ readbacks: decision.readbacksConsulted,
361
+ decision: 'skip-already-applied',
362
+ },
363
+ });
364
+ }
365
+ /** JSON round-trip a value to detect serializability. Returns the round-
366
+ * tripped value on success, undefined on any failure (circular refs,
367
+ * bigint, function, undefined, etc.). Persisting only round-trippable
368
+ * values keeps the snapshot file deterministic and prevents subtle
369
+ * type-drift between the in-memory value and what gets restored. */
370
+ function jsonRoundTrip(value) {
371
+ if (value === undefined)
372
+ return undefined;
373
+ try {
374
+ const serialized = JSON.stringify(value);
375
+ if (serialized === undefined)
376
+ return undefined;
377
+ return JSON.parse(serialized);
378
+ }
379
+ catch {
380
+ return undefined;
381
+ }
382
+ }
383
+ /** Build a `subPhase` callable bound to a parent phase. Sub-phases use a
384
+ * synthetic phaseIdx derived from the parent's index plus a monotonic
385
+ * counter so the durable log distinguishes "outer phase 1, child 0" from
386
+ * "outer phase 1, child 1".
387
+ *
388
+ * Encoding: subPhase index = (parentPhaseIdx + 1) * 1000 + childOrdinal.
389
+ * The +1 offset is critical: without it, parent index 0 (the FIRST phase
390
+ * of any pipeline, since createRun is 0-based) would yield child indices
391
+ * 1, 2, 3… which collide with the regular top-level phases at those
392
+ * exact indices — a sub-phase's idempotency / side-effect events would
393
+ * then incorrectly gate the real top-level phase. Caught by Cursor
394
+ * Bugbot on PR #87 (HIGH). With the +1 offset:
395
+ * parent=0 → children 1001, 1002, 1003
396
+ * parent=1 → children 2001, 2002, 2003
397
+ * parent=N (N<999) → children (N+1)*1000+1..N
398
+ * Top-level pipelines have ~10 phases in practice, so the 1000 multiplier
399
+ * + the +1 offset keep collisions impossible at any realistic depth.
400
+ * Phase 6 may revisit this if nested sub-phases ever need a real tree
401
+ * representation. */
402
+ function makeSubPhaseFactory(opts) {
403
+ let childOrdinal = 0;
404
+ return async function subPhase(child, input) {
405
+ const childIdx = (opts.parentPhaseIdx + 1) * 1000 + (childOrdinal += 1);
406
+ return runPhase(child, input, {
407
+ runDir: opts.runDir,
408
+ runId: opts.runId,
409
+ writerId: opts.writerId,
410
+ phaseIdx: childIdx,
411
+ });
412
+ };
413
+ }
414
+ // ----------------------------------------------------------------------------
415
+ // Phase 4 — budget helpers
416
+ // ----------------------------------------------------------------------------
417
+ /** Sum every `phase.cost` event across the WHOLE run (not just the current
418
+ * phaseIdx). The budget cap is run-wide; sub-phase costs and prior-phase
419
+ * costs both count against `perRunUSD`. */
420
+ function sumRunCost(events) {
421
+ let total = 0;
422
+ for (const ev of events) {
423
+ if (ev.event === 'phase.cost')
424
+ total += ev.costUSD;
425
+ }
426
+ return total;
427
+ }
428
+ /** Default interactive confirm prompt used when no `confirmBudgetPause`
429
+ * override is supplied. Uses node:readline so the runner doesn't pull in
430
+ * a dependency just for prompting. */
431
+ async function defaultConfirmBudgetPause(check) {
432
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
433
+ try {
434
+ const message = `Budget warning: ${check.reason}\n` +
435
+ ` phase: ${check.phase} (idx ${check.phaseIdx})\n` +
436
+ ` actualSoFar: $${check.actualSoFar.toFixed(2)}\n` +
437
+ ` reserveApplied: $${check.reserveApplied.toFixed(2)}\n` +
438
+ ` capRemaining: $${check.capRemaining.toFixed(2)}\n` +
439
+ `Continue and accept the overage? [y/N] `;
440
+ const answer = await new Promise(resolve => rl.question(message, resolve));
441
+ return /^y(es)?$/i.test(answer.trim());
442
+ }
443
+ finally {
444
+ rl.close();
445
+ }
446
+ }
447
+ //# sourceMappingURL=phase-runner.js.map
@@ -0,0 +1,130 @@
1
+ import type { ExternalRef, ExternalRefKind } from './types.ts';
2
+ /** Canonical platform-state vocabulary. Every readback maps its provider's
3
+ * raw state into one of these so the decision matrix stays provider-agnostic.
4
+ * `unknown` is the fail-closed sentinel — any time the readback can't make a
5
+ * confident assertion it returns `unknown` and the caller treats that as
6
+ * needs-human. */
7
+ export type ReadbackState = 'open' | 'closed' | 'merged' | 'live' | 'failed' | 'rolled-back' | 'unknown';
8
+ /** What a readback returns when asked to verify a single external ref. */
9
+ export interface ReadbackResult {
10
+ refKind: ExternalRefKind;
11
+ refId: string;
12
+ /** Whether the platform reports the ref still exists. False on 404,
13
+ * hard error, missing ID, or any throw. */
14
+ existsOnPlatform: boolean;
15
+ currentState: ReadbackState;
16
+ /** Free-form provider-specific metadata. Engine doesn't introspect.
17
+ * Surfaces in the replay decision's `details` for human triage. */
18
+ metadata?: Record<string, unknown>;
19
+ }
20
+ /** A readback verifies one ref kind against its source-of-truth platform.
21
+ * Implementations MUST NOT throw — any failure (network, auth, unknown
22
+ * shape) collapses to `existsOnPlatform: false, currentState: 'unknown'`.
23
+ * This is the fail-closed contract: an unknown-state readback always
24
+ * routes to needs-human, never to a silent skip-already-applied. */
25
+ export interface ProviderReadback {
26
+ /** Stable identifier — useful in logs / decision details. */
27
+ readonly name: string;
28
+ /** Which ref kinds this readback handles. The registry filters first by
29
+ * kind; if multiple entries match a kind, `providers` then disambiguates
30
+ * on `ref.provider`. */
31
+ readonly handles: ReadonlyArray<ExternalRefKind>;
32
+ /** Optional provider-name allowlist. When present, the registry only
33
+ * routes a ref to this readback if `ref.provider` is in this list. Lets
34
+ * multiple readbacks share a kind (e.g. vercel/fly/render all handle
35
+ * `deploy`) without shadowing each other. Omit for kind-exclusive
36
+ * readbacks (e.g. github handles `github-pr`). */
37
+ readonly providers?: ReadonlyArray<string>;
38
+ verifyRef(ref: ExternalRef): Promise<ReadbackResult>;
39
+ }
40
+ /** Test seam — replace the gh CLI invocation in tests without monkey-patching
41
+ * child_process. Returns null on any failure (matches runSafe semantics). */
42
+ export interface GhRunner {
43
+ (args: string[]): string | null;
44
+ }
45
+ export declare function makeGithubReadback(opts?: {
46
+ gh?: GhRunner;
47
+ }): ProviderReadback;
48
+ /** Minimal adapter-status surface. Mirrors `DeployAdapter.status()` from
49
+ * `src/adapters/deploy/types.ts` but typed locally so this module doesn't
50
+ * pull the adapter package at init time. */
51
+ export interface DeployStatusFetcher {
52
+ status(input: {
53
+ deployId: string;
54
+ }): Promise<{
55
+ status: 'pass' | 'fail' | 'in-progress' | 'fail_rolled_back' | 'fail_rollback_failed';
56
+ deployId: string;
57
+ deployUrl?: string;
58
+ }>;
59
+ }
60
+ export type DeployAdapterResolver = (provider: string) => DeployStatusFetcher | null;
61
+ /** Register a resolver that maps a provider name (e.g. "vercel") to a
62
+ * status-fetcher. The CLI wires this from `src/adapters/deploy/index.ts`
63
+ * during boot; tests inject mocks directly. */
64
+ export declare function registerDeployAdapterResolver(resolver: DeployAdapterResolver | null): void;
65
+ /** Reset the registered resolver. Test-only seam. */
66
+ export declare function __resetDeployAdapterResolver(): void;
67
+ export declare function makeDeployReadback(name: string, providers: ReadonlyArray<string>): ProviderReadback;
68
+ /** Minimal migration-state fetcher. Implementations query the per-env
69
+ * Supabase project's `migration_state` table. We type it abstractly so this
70
+ * module doesn't pull the supabase client at init time. Returning null
71
+ * indicates "fetch failed" — fail-closed treats it as unknown. */
72
+ export interface MigrationStateFetcher {
73
+ /** Look up a migration version. Returns null on any error or not-found. */
74
+ fetch(version: string): Promise<{
75
+ applied: boolean;
76
+ appliedAt?: string;
77
+ } | null>;
78
+ }
79
+ /** Register the migration-state fetcher used by the supabase readback.
80
+ * CLI boot wires this; tests inject directly. */
81
+ export declare function registerMigrationStateFetcher(fetcher: MigrationStateFetcher | null): void;
82
+ export declare function __resetMigrationStateFetcher(): void;
83
+ /** State of a single planned migration as reported by the dispatcher's
84
+ * ledger. The fetcher returns the per-batch plan + the live ledger view so
85
+ * the readback can compute the aggregate state without re-querying. */
86
+ export interface MigrationBatchPlannedItem {
87
+ /** Migration version (matches the post-effect `migration-version` ref id
88
+ * shape — `<env>:<migration>` is the externalRef id, but the planned
89
+ * list carries just the migration name). */
90
+ version: string;
91
+ /** Live ledger state. `applied` ⇒ merged, `pending` ⇒ open, `errored` ⇒
92
+ * failed. */
93
+ state: 'applied' | 'pending' | 'errored';
94
+ }
95
+ /** Minimal `migration-batch` fetcher. Looks up the planned set for a batch
96
+ * ref id (typically `${env}:${hash}` or `${env}:pre-dispatch:${ts}` per the
97
+ * v6.2.1 spec) and returns the live ledger state of each. Returning null
98
+ * indicates "no plan recorded for this batch" — the readback treats that
99
+ * as unknown (fail closed). */
100
+ export interface MigrationBatchFetcher {
101
+ fetch(batchId: string): Promise<{
102
+ planned: MigrationBatchPlannedItem[];
103
+ } | null>;
104
+ }
105
+ /** Register the `migration-batch` fetcher. The CLI boot wires this from the
106
+ * per-skill adapter; tests inject mocks directly. */
107
+ export declare function registerMigrationBatchFetcher(fetcher: MigrationBatchFetcher | null): void;
108
+ export declare function __resetMigrationBatchFetcher(): void;
109
+ export declare function makeSupabaseReadback(): ProviderReadback;
110
+ /** Live registry — exposed as a getter so tests / callers can introspect. */
111
+ export declare function getProviderReadbacks(): ReadonlyArray<ProviderReadback>;
112
+ /** Replace the registry (test seam). Pass null to reset to defaults. */
113
+ export declare function setProviderReadbacks(list: ProviderReadback[] | null): void;
114
+ /** Look up the readback that handles a given ref. Two-pass match: first try
115
+ * a strict (kind + provider) match so multiple readbacks sharing a kind
116
+ * (vercel/fly/render all on `deploy`) don't shadow each other; then fall
117
+ * back to a kind-only match for readbacks that don't declare a provider
118
+ * allowlist (e.g. the github readback handles `github-pr` regardless of
119
+ * ref.provider). Returns null if no registered readback claims this ref —
120
+ * caller treats null as "no readback available, route to needs-human".
121
+ *
122
+ * Bugbot MEDIUM (PR #91): without provider-aware matching, the first deploy
123
+ * readback registered (vercel) won every `deploy`/`rollback-target` lookup
124
+ * and the fly/render readbacks were dead code. */
125
+ export declare function readbackForRef(ref: ExternalRef): ProviderReadback | null;
126
+ /** Verify a list of refs in parallel. Returns one ReadbackResult per ref.
127
+ * Refs without a registered readback get an unknown-state result so the
128
+ * decision matrix can attribute the gap. Order is preserved. */
129
+ export declare function verifyRefs(refs: ReadonlyArray<ExternalRef>): Promise<ReadbackResult[]>;
130
+ //# sourceMappingURL=provider-readback.d.ts.map