@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,426 @@
1
+ // src/core/run-state/provider-readback.ts
2
+ //
3
+ // v6 Phase 6 — pluggable provider read-back layer.
4
+ //
5
+ // When the run-state engine resumes a run that has prior `phase.success` +
6
+ // side-effects + persisted `externalRefs`, the replay decision (see
7
+ // `replay-decision.ts`) is NOT pure — it MUST consult the platform of record
8
+ // to confirm the ref is still live and in the expected state. e.g. for a
9
+ // `github-pr` ref we ask `gh pr view <id> --json state` and inspect
10
+ // open / closed / merged. For a `deploy` ref we ask the adapter's `status()`.
11
+ //
12
+ // This file is the seam: a `ProviderReadback` interface, a registry mapping
13
+ // `ExternalRef.kind` to an implementation, and the built-in readbacks for
14
+ // github / vercel / fly / render / supabase. Each readback FAILS CLOSED — any
15
+ // throw or unrecognized response shape is recorded as
16
+ // `existsOnPlatform: false, currentState: 'unknown'`. Callers (the replay
17
+ // decision matrix) treat unknown-state as `needs-human` so we never quietly
18
+ // overwrite or duplicate a side effect on a missing/stale ref.
19
+ //
20
+ // Spec: docs/specs/v6-run-state-engine.md "Idempotency rules + external
21
+ // operation ledger (Codex CRITICAL #2)" — replay decision is "persisted refs
22
+ // + a provider read-back check (e.g., 'is PR #123 still open?')".
23
+ import { runSafe } from "../shell.js";
24
+ // ---------------------------------------------------------------------------
25
+ // Wrapping helper — guarantees the fail-closed contract regardless of impl.
26
+ // ---------------------------------------------------------------------------
27
+ /** Wrap a readback so that any throw collapses to the unknown-state result.
28
+ * All built-in readbacks below opt into this; external implementations are
29
+ * free to use it too. Centralizes the fail-closed invariant. */
30
+ function failClosed(name, ref, fn) {
31
+ return fn().catch(() => unknownResult(ref, { readback: name, threw: true }));
32
+ }
33
+ /** Build a fail-closed result for a ref that the readback couldn't verify. */
34
+ function unknownResult(ref, metadata) {
35
+ return {
36
+ refKind: ref.kind,
37
+ refId: ref.id,
38
+ existsOnPlatform: false,
39
+ currentState: 'unknown',
40
+ ...(metadata ? { metadata } : {}),
41
+ };
42
+ }
43
+ const defaultGhRunner = (args) => runSafe('gh', args, { timeout: 30000 });
44
+ export function makeGithubReadback(opts = {}) {
45
+ const gh = opts.gh ?? defaultGhRunner;
46
+ return {
47
+ name: 'github',
48
+ handles: ['github-pr', 'github-comment', 'git-remote-push'],
49
+ verifyRef: (ref) => failClosed('github', ref, async () => {
50
+ if (ref.kind === 'github-pr')
51
+ return verifyGithubPr(gh, ref);
52
+ if (ref.kind === 'github-comment')
53
+ return verifyGithubComment(gh, ref);
54
+ if (ref.kind === 'git-remote-push')
55
+ return verifyGitRemotePush(gh, ref);
56
+ return unknownResult(ref, { readback: 'github', reason: 'unsupported-kind' });
57
+ }),
58
+ };
59
+ }
60
+ async function verifyGithubPr(gh, ref) {
61
+ // `gh pr view <id> --json state,url,title,merged` — single deterministic
62
+ // call. PR ID may be a bare number ("99") or a full URL.
63
+ const out = gh(['pr', 'view', ref.id, '--json', 'state,url,title,merged']);
64
+ if (out === null)
65
+ return unknownResult(ref, { readback: 'github-pr', reason: 'gh-cli-failed' });
66
+ let parsed;
67
+ try {
68
+ parsed = JSON.parse(out);
69
+ }
70
+ catch {
71
+ return unknownResult(ref, { readback: 'github-pr', reason: 'unparseable-json' });
72
+ }
73
+ // Map gh's state vocabulary onto ours. gh returns OPEN | CLOSED | MERGED.
74
+ // `merged: true` overrides — a closed-merged PR is "merged", not "closed".
75
+ let currentState;
76
+ if (parsed.merged === true || parsed.state === 'MERGED')
77
+ currentState = 'merged';
78
+ else if (parsed.state === 'OPEN')
79
+ currentState = 'open';
80
+ else if (parsed.state === 'CLOSED')
81
+ currentState = 'closed';
82
+ else
83
+ currentState = 'unknown';
84
+ return {
85
+ refKind: ref.kind,
86
+ refId: ref.id,
87
+ existsOnPlatform: true,
88
+ currentState,
89
+ metadata: {
90
+ readback: 'github-pr',
91
+ ...(parsed.url ? { url: parsed.url } : {}),
92
+ ...(parsed.title ? { title: parsed.title } : {}),
93
+ rawState: parsed.state,
94
+ },
95
+ };
96
+ }
97
+ async function verifyGithubComment(gh, ref) {
98
+ // gh doesn't have a clean per-comment-ID lookup — we use `gh api` against
99
+ // the issues comments endpoint. Comment IDs are integers; if the ref id is
100
+ // qualified as `<repo>:<id>` we split, else we rely on cwd's repo context.
101
+ let endpoint;
102
+ if (ref.id.includes(':')) {
103
+ const [repo, commentId] = ref.id.split(':', 2);
104
+ endpoint = `/repos/${repo}/issues/comments/${commentId}`;
105
+ }
106
+ else {
107
+ endpoint = `/repos/{owner}/{repo}/issues/comments/${ref.id}`;
108
+ }
109
+ const out = gh(['api', endpoint]);
110
+ if (out === null) {
111
+ // gh api returns non-zero on 404. Treat as does-not-exist (which is
112
+ // distinct from unknown — a deleted comment is meaningful: replay would
113
+ // create a new comment, so the prior ref is no longer authoritative).
114
+ return {
115
+ refKind: ref.kind,
116
+ refId: ref.id,
117
+ existsOnPlatform: false,
118
+ currentState: 'closed',
119
+ metadata: { readback: 'github-comment', reason: 'gh-api-failed-or-404' },
120
+ };
121
+ }
122
+ let parsed;
123
+ try {
124
+ parsed = JSON.parse(out);
125
+ }
126
+ catch {
127
+ return unknownResult(ref, { readback: 'github-comment', reason: 'unparseable-json' });
128
+ }
129
+ return {
130
+ refKind: ref.kind,
131
+ refId: ref.id,
132
+ existsOnPlatform: typeof parsed.id === 'number',
133
+ currentState: typeof parsed.id === 'number' ? 'open' : 'unknown',
134
+ metadata: {
135
+ readback: 'github-comment',
136
+ ...(parsed.html_url ? { url: parsed.html_url } : {}),
137
+ },
138
+ };
139
+ }
140
+ async function verifyGitRemotePush(gh, ref) {
141
+ // For a git-remote-push ref the id is the commit SHA. We confirm it exists
142
+ // on the remote by asking gh for the commit. Treat "not found" as
143
+ // does-not-exist (rebased away), distinct from unknown (auth/network).
144
+ // gh api format: /repos/{owner}/{repo}/commits/<sha>.
145
+ const out = gh(['api', `/repos/{owner}/{repo}/commits/${ref.id}`]);
146
+ if (out === null) {
147
+ return {
148
+ refKind: ref.kind,
149
+ refId: ref.id,
150
+ existsOnPlatform: false,
151
+ currentState: 'closed',
152
+ metadata: { readback: 'git-remote-push', reason: 'gh-api-failed-or-404' },
153
+ };
154
+ }
155
+ let parsed;
156
+ try {
157
+ parsed = JSON.parse(out);
158
+ }
159
+ catch {
160
+ return unknownResult(ref, { readback: 'git-remote-push', reason: 'unparseable-json' });
161
+ }
162
+ return {
163
+ refKind: ref.kind,
164
+ refId: ref.id,
165
+ existsOnPlatform: typeof parsed.sha === 'string',
166
+ currentState: typeof parsed.sha === 'string' ? 'live' : 'unknown',
167
+ metadata: {
168
+ readback: 'git-remote-push',
169
+ ...(parsed.html_url ? { url: parsed.html_url } : {}),
170
+ },
171
+ };
172
+ }
173
+ let deployAdapterResolver = null;
174
+ /** Register a resolver that maps a provider name (e.g. "vercel") to a
175
+ * status-fetcher. The CLI wires this from `src/adapters/deploy/index.ts`
176
+ * during boot; tests inject mocks directly. */
177
+ export function registerDeployAdapterResolver(resolver) {
178
+ deployAdapterResolver = resolver;
179
+ }
180
+ /** Reset the registered resolver. Test-only seam. */
181
+ export function __resetDeployAdapterResolver() {
182
+ deployAdapterResolver = null;
183
+ }
184
+ export function makeDeployReadback(name, providers) {
185
+ return {
186
+ name,
187
+ handles: ['deploy', 'rollback-target'],
188
+ providers,
189
+ verifyRef: (ref) => failClosed(name, ref, async () => {
190
+ const provider = ref.provider ?? null;
191
+ if (!provider || !providers.includes(provider)) {
192
+ return unknownResult(ref, {
193
+ readback: name,
194
+ reason: 'provider-mismatch',
195
+ refProvider: provider,
196
+ });
197
+ }
198
+ if (!deployAdapterResolver) {
199
+ return unknownResult(ref, {
200
+ readback: name,
201
+ reason: 'no-adapter-resolver-registered',
202
+ });
203
+ }
204
+ const fetcher = deployAdapterResolver(provider);
205
+ if (!fetcher) {
206
+ return unknownResult(ref, {
207
+ readback: name,
208
+ reason: 'adapter-not-resolvable',
209
+ provider,
210
+ });
211
+ }
212
+ const r = await fetcher.status({ deployId: ref.id });
213
+ // Map adapter status → ReadbackState. The adapter contract returns
214
+ // 'pass'|'fail'|'in-progress'|'fail_rolled_back'|'fail_rollback_failed'.
215
+ let currentState;
216
+ switch (r.status) {
217
+ case 'pass':
218
+ currentState = 'live';
219
+ break;
220
+ case 'fail':
221
+ case 'fail_rollback_failed':
222
+ currentState = 'failed';
223
+ break;
224
+ case 'fail_rolled_back':
225
+ currentState = 'rolled-back';
226
+ break;
227
+ case 'in-progress':
228
+ currentState = 'open';
229
+ break;
230
+ default:
231
+ currentState = 'unknown';
232
+ }
233
+ return {
234
+ refKind: ref.kind,
235
+ refId: ref.id,
236
+ existsOnPlatform: true,
237
+ currentState,
238
+ metadata: {
239
+ readback: name,
240
+ provider,
241
+ rawStatus: r.status,
242
+ ...(r.deployUrl ? { deployUrl: r.deployUrl } : {}),
243
+ },
244
+ };
245
+ }),
246
+ };
247
+ }
248
+ let migrationStateFetcher = null;
249
+ /** Register the migration-state fetcher used by the supabase readback.
250
+ * CLI boot wires this; tests inject directly. */
251
+ export function registerMigrationStateFetcher(fetcher) {
252
+ migrationStateFetcher = fetcher;
253
+ }
254
+ export function __resetMigrationStateFetcher() {
255
+ migrationStateFetcher = null;
256
+ }
257
+ let migrationBatchFetcher = null;
258
+ /** Register the `migration-batch` fetcher. The CLI boot wires this from the
259
+ * per-skill adapter; tests inject mocks directly. */
260
+ export function registerMigrationBatchFetcher(fetcher) {
261
+ migrationBatchFetcher = fetcher;
262
+ }
263
+ export function __resetMigrationBatchFetcher() {
264
+ migrationBatchFetcher = null;
265
+ }
266
+ export function makeSupabaseReadback() {
267
+ return {
268
+ name: 'supabase',
269
+ handles: ['migration-version', 'migration-batch'],
270
+ verifyRef: (ref) => failClosed('supabase', ref, async () => {
271
+ if (ref.kind === 'migration-batch')
272
+ return verifyMigrationBatch(ref);
273
+ // migration-version
274
+ if (!migrationStateFetcher) {
275
+ return unknownResult(ref, {
276
+ readback: 'supabase',
277
+ reason: 'no-migration-state-fetcher-registered',
278
+ });
279
+ }
280
+ const result = await migrationStateFetcher.fetch(ref.id);
281
+ if (!result) {
282
+ return unknownResult(ref, {
283
+ readback: 'supabase',
284
+ reason: 'migration-state-fetch-failed-or-not-found',
285
+ });
286
+ }
287
+ return {
288
+ refKind: ref.kind,
289
+ refId: ref.id,
290
+ existsOnPlatform: true,
291
+ currentState: result.applied ? 'live' : 'open',
292
+ metadata: {
293
+ readback: 'supabase',
294
+ applied: result.applied,
295
+ ...(result.appliedAt ? { appliedAt: result.appliedAt } : {}),
296
+ },
297
+ };
298
+ }),
299
+ };
300
+ }
301
+ async function verifyMigrationBatch(ref) {
302
+ if (!migrationBatchFetcher) {
303
+ return unknownResult(ref, {
304
+ readback: 'supabase',
305
+ reason: 'no-migration-batch-fetcher-registered',
306
+ });
307
+ }
308
+ const result = await migrationBatchFetcher.fetch(ref.id);
309
+ if (!result) {
310
+ return unknownResult(ref, {
311
+ readback: 'supabase',
312
+ reason: 'migration-batch-fetch-failed-or-not-found',
313
+ });
314
+ }
315
+ if (result.planned.length === 0) {
316
+ // A planned-empty batch is degenerate — no work to verify against. Treat
317
+ // it as merged (skip-already-applied) rather than unknown so a batch ref
318
+ // emitted before the dispatcher discovered "nothing to do" doesn't
319
+ // wedge the resume preflight on needs-human. The post-effect ref set is
320
+ // also empty in this case, so the orchestrator's "all post-effect refs
321
+ // merged/live" check naturally short-circuits to skip.
322
+ return {
323
+ refKind: ref.kind,
324
+ refId: ref.id,
325
+ existsOnPlatform: true,
326
+ currentState: 'merged',
327
+ metadata: {
328
+ readback: 'supabase',
329
+ plannedCount: 0,
330
+ appliedCount: 0,
331
+ erroredCount: 0,
332
+ },
333
+ };
334
+ }
335
+ let appliedCount = 0;
336
+ let pendingCount = 0;
337
+ let erroredCount = 0;
338
+ for (const item of result.planned) {
339
+ if (item.state === 'applied')
340
+ appliedCount++;
341
+ else if (item.state === 'pending')
342
+ pendingCount++;
343
+ else if (item.state === 'errored')
344
+ erroredCount++;
345
+ }
346
+ let currentState;
347
+ if (erroredCount > 0)
348
+ currentState = 'failed';
349
+ else if (pendingCount === 0)
350
+ currentState = 'merged';
351
+ else
352
+ currentState = 'open';
353
+ return {
354
+ refKind: ref.kind,
355
+ refId: ref.id,
356
+ existsOnPlatform: true,
357
+ currentState,
358
+ metadata: {
359
+ readback: 'supabase',
360
+ plannedCount: result.planned.length,
361
+ appliedCount,
362
+ pendingCount,
363
+ erroredCount,
364
+ },
365
+ };
366
+ }
367
+ // ---------------------------------------------------------------------------
368
+ // Registry — first-match-wins lookup keyed on ExternalRefKind.
369
+ // ---------------------------------------------------------------------------
370
+ /** Default built-in registry. Order matters: first readback whose `handles`
371
+ * contains the ref kind wins. Callers may swap individual entries via
372
+ * `setProviderReadbacks` (test-only seam). */
373
+ function buildDefaultRegistry() {
374
+ return [
375
+ makeGithubReadback(),
376
+ makeDeployReadback('vercel', ['vercel']),
377
+ makeDeployReadback('fly', ['fly']),
378
+ makeDeployReadback('render', ['render']),
379
+ makeSupabaseReadback(),
380
+ ];
381
+ }
382
+ let providerReadbacks = buildDefaultRegistry();
383
+ /** Live registry — exposed as a getter so tests / callers can introspect. */
384
+ export function getProviderReadbacks() {
385
+ return providerReadbacks;
386
+ }
387
+ /** Replace the registry (test seam). Pass null to reset to defaults. */
388
+ export function setProviderReadbacks(list) {
389
+ providerReadbacks = list === null ? buildDefaultRegistry() : list;
390
+ }
391
+ /** Look up the readback that handles a given ref. Two-pass match: first try
392
+ * a strict (kind + provider) match so multiple readbacks sharing a kind
393
+ * (vercel/fly/render all on `deploy`) don't shadow each other; then fall
394
+ * back to a kind-only match for readbacks that don't declare a provider
395
+ * allowlist (e.g. the github readback handles `github-pr` regardless of
396
+ * ref.provider). Returns null if no registered readback claims this ref —
397
+ * caller treats null as "no readback available, route to needs-human".
398
+ *
399
+ * Bugbot MEDIUM (PR #91): without provider-aware matching, the first deploy
400
+ * readback registered (vercel) won every `deploy`/`rollback-target` lookup
401
+ * and the fly/render readbacks were dead code. */
402
+ export function readbackForRef(ref) {
403
+ if (ref.provider) {
404
+ for (const rb of providerReadbacks) {
405
+ if (rb.handles.includes(ref.kind) && rb.providers?.includes(ref.provider))
406
+ return rb;
407
+ }
408
+ }
409
+ for (const rb of providerReadbacks) {
410
+ if (rb.handles.includes(ref.kind) && !rb.providers)
411
+ return rb;
412
+ }
413
+ return null;
414
+ }
415
+ /** Verify a list of refs in parallel. Returns one ReadbackResult per ref.
416
+ * Refs without a registered readback get an unknown-state result so the
417
+ * decision matrix can attribute the gap. Order is preserved. */
418
+ export async function verifyRefs(refs) {
419
+ return Promise.all(refs.map(async (ref) => {
420
+ const rb = readbackForRef(ref);
421
+ if (!rb)
422
+ return unknownResult(ref, { reason: 'no-readback-registered' });
423
+ return rb.verifyRef(ref);
424
+ }));
425
+ }
426
+ //# sourceMappingURL=provider-readback.js.map
@@ -0,0 +1,69 @@
1
+ import type { ExternalRef } from './types.ts';
2
+ import type { ReadbackResult } from './provider-readback.ts';
3
+ /** Decision the engine should take when replaying / resuming a phase. */
4
+ export type ReplayDecisionKind =
5
+ /** Run the phase body. Default for fresh attempts and post-failure retries. */
6
+ 'retry'
7
+ /** Don't run; treat as already-done. Engine returns prior output / snapshot. */
8
+ | 'skip-already-applied'
9
+ /** Don't run; can't safely decide. Engine emits phase.needs-human + throws. */
10
+ | 'needs-human'
11
+ /** Don't run; explicit user/CI signal to give up. Engine throws abort code. */
12
+ | 'abort';
13
+ export interface ReplayDecision {
14
+ decision: ReplayDecisionKind;
15
+ /** Single-line human-readable explanation. Embedded into needs-human events
16
+ * and surface in `runs resume` output. */
17
+ reason: string;
18
+ /** External refs the decision considered. Echoed back so CI/humans can
19
+ * inspect them without re-reading the events log. */
20
+ refsConsulted: ExternalRef[];
21
+ /** Per-ref readback results. Empty array when the decision was made
22
+ * without consulting readbacks (e.g. retry on no-prior-success). */
23
+ readbacksConsulted: ReadbackResult[];
24
+ }
25
+ /** Inputs to `decideReplay`. All fields required so callers can't accidentally
26
+ * drop a signal. Keep in lockstep with runPhase's gating logic. */
27
+ export interface ReplayDecisionInput {
28
+ /** Phase name — for the reason string only; no behavior depends on it. */
29
+ phaseName: string;
30
+ /** True iff prior `phase.success` event exists for this phaseIdx. */
31
+ hasPriorSuccess: boolean;
32
+ /** Total attempts recorded in state.json for this phaseIdx (failed +
33
+ * succeeded). Used only for the `reason` string when there's no prior
34
+ * success but priorAttempts > 0 — distinguishes "first attempt" from
35
+ * "post-failure retry" so users running `runs resume` get an accurate
36
+ * description. No behavior depends on this; it's a presentation field.
37
+ * Defaults to 0 when omitted (Bugbot LOW PR #91 fold-in). */
38
+ priorAttempts?: number;
39
+ /** Mirrors RunPhase.idempotent declared at registration. */
40
+ idempotent: boolean;
41
+ /** Mirrors RunPhase.hasSideEffects declared at registration. */
42
+ hasSideEffects: boolean;
43
+ /** All externalRefs persisted for this phaseIdx across prior attempts. */
44
+ externalRefs: ExternalRef[];
45
+ /** Readback results, one per externalRef in the same order. May be empty
46
+ * when the caller is doing pure-state lookup (CLI `runs resume`) — in
47
+ * that case any side-effect-phase prior success collapses to needs-human
48
+ * because we have no live confirmation. */
49
+ readbacks: ReadbackResult[];
50
+ /** When true the user/CI explicitly asked to override needs-human. The
51
+ * engine emits a `replay.override` event; this function flips the
52
+ * decision to retry regardless of state. */
53
+ forceReplay: boolean;
54
+ }
55
+ /** Decide what to do with a (re-)attempt of a phase. Pure; safe to call
56
+ * during CLI lookup AND inside runPhase. The decision matrix mirrors the
57
+ * spec's per-phase replay table:
58
+ *
59
+ * | prior success | idempotent | sideEffects | refs | readback all valid | -> decision |
60
+ * | no | - | - | - | - | retry |
61
+ * | yes | yes | - | - | - | skip |
62
+ * | yes | no | no | - | - | skip |
63
+ * | yes | no | yes | empty | - | needs-human |
64
+ * | yes | no | yes | non-empty | all valid | skip |
65
+ * | yes | no | yes | non-empty | any missing/stale | needs-human |
66
+ *
67
+ * forceReplay = true overrides everything → retry. */
68
+ export declare function decideReplay(input: ReplayDecisionInput): ReplayDecision;
69
+ //# sourceMappingURL=replay-decision.d.ts.map
@@ -0,0 +1,144 @@
1
+ // src/core/run-state/replay-decision.ts
2
+ //
3
+ // v6 Phase 6 — pure decision function for "should this phase replay?".
4
+ //
5
+ // Inputs are the persisted facts of a prior phase attempt (success count,
6
+ // idempotent / hasSideEffects declarations, externalRefs) plus the live
7
+ // readback results from `provider-readback.ts`. Output is one of four
8
+ // decisions, plus the refs + readbacks the decision was based on so callers
9
+ // can surface them in `phase.needs-human` events for human triage.
10
+ //
11
+ // This file is deliberately pure: it does NOT execute readbacks itself
12
+ // (caller passes them in), it does NOT consult disk, it does NOT throw on
13
+ // any input shape. Easy to unit-test exhaustively against the spec's
14
+ // per-phase replay table.
15
+ //
16
+ // Spec: docs/specs/v6-run-state-engine.md "Idempotency rules + external
17
+ // operation ledger (Codex CRITICAL #2)" — the replay matrix.
18
+ /** Decide what to do with a (re-)attempt of a phase. Pure; safe to call
19
+ * during CLI lookup AND inside runPhase. The decision matrix mirrors the
20
+ * spec's per-phase replay table:
21
+ *
22
+ * | prior success | idempotent | sideEffects | refs | readback all valid | -> decision |
23
+ * | no | - | - | - | - | retry |
24
+ * | yes | yes | - | - | - | skip |
25
+ * | yes | no | no | - | - | skip |
26
+ * | yes | no | yes | empty | - | needs-human |
27
+ * | yes | no | yes | non-empty | all valid | skip |
28
+ * | yes | no | yes | non-empty | any missing/stale | needs-human |
29
+ *
30
+ * forceReplay = true overrides everything → retry. */
31
+ export function decideReplay(input) {
32
+ const refsConsulted = [...input.externalRefs];
33
+ const readbacksConsulted = [...input.readbacks];
34
+ // Override path — caller already gated this on user/CI consent. Engine
35
+ // emits replay.override on this branch.
36
+ if (input.forceReplay) {
37
+ return {
38
+ decision: 'retry',
39
+ reason: `forceReplay override: ${input.phaseName} will re-execute despite prior state`,
40
+ refsConsulted,
41
+ readbacksConsulted,
42
+ };
43
+ }
44
+ // No prior success → fresh attempt or post-failure retry. Always safe.
45
+ if (!input.hasPriorSuccess) {
46
+ const priorAttempts = input.priorAttempts ?? 0;
47
+ const reason = priorAttempts > 0
48
+ ? `${input.phaseName} previous attempt(s) failed (${priorAttempts}) — retry safe`
49
+ : `${input.phaseName} has no prior success — first attempt`;
50
+ return {
51
+ decision: 'retry',
52
+ reason,
53
+ refsConsulted,
54
+ readbacksConsulted: [],
55
+ };
56
+ }
57
+ // Prior success + declared idempotent → safe to short-circuit. The phase
58
+ // contract promises the prior output is durable / retrievable.
59
+ if (input.idempotent) {
60
+ return {
61
+ decision: 'skip-already-applied',
62
+ reason: `${input.phaseName} previously succeeded and is idempotent — replay short-circuits`,
63
+ refsConsulted,
64
+ readbacksConsulted: [],
65
+ };
66
+ }
67
+ // Prior success + no side effects → still safe to skip. The phase
68
+ // produced no observable platform state; replay would just re-do the
69
+ // identical no-side-effect work.
70
+ if (!input.hasSideEffects) {
71
+ return {
72
+ decision: 'skip-already-applied',
73
+ reason: `${input.phaseName} previously succeeded with no side effects — skip-already-applied`,
74
+ refsConsulted,
75
+ readbacksConsulted: [],
76
+ };
77
+ }
78
+ // Prior success + side effects + no refs → we can't reach the platform of
79
+ // record to confirm anything. Bubble to a human; the spec is explicit
80
+ // that missing refs always route to needs-human.
81
+ if (input.externalRefs.length === 0) {
82
+ return {
83
+ decision: 'needs-human',
84
+ reason: `${input.phaseName} previously succeeded with side effects but recorded no externalRefs — cannot verify, needs human review`,
85
+ refsConsulted,
86
+ readbacksConsulted: [],
87
+ };
88
+ }
89
+ // Prior success + side effects + refs but no readbacks supplied (CLI
90
+ // lookup mode): we must NOT silently skip. Surface as needs-human so the
91
+ // CLI prediction matches what runPhase will do under live conditions.
92
+ if (input.readbacks.length === 0) {
93
+ return {
94
+ decision: 'needs-human',
95
+ reason: `${input.phaseName} previously succeeded with side effects; no live readback was performed — needs human review (or pass --force-replay)`,
96
+ refsConsulted,
97
+ readbacksConsulted: [],
98
+ };
99
+ }
100
+ // Refs + readbacks both present — adjudicate per readback validity.
101
+ const stale = readbacksConsulted.filter(rb => !isReadbackValid(rb));
102
+ if (stale.length > 0) {
103
+ const summary = stale
104
+ .map(rb => `${rb.refKind}=${rb.refId} state=${rb.currentState}`)
105
+ .join(', ');
106
+ return {
107
+ decision: 'needs-human',
108
+ reason: `${input.phaseName} previously succeeded but ${stale.length} ref(s) are stale or missing on the platform: ${summary}`,
109
+ refsConsulted,
110
+ readbacksConsulted,
111
+ };
112
+ }
113
+ return {
114
+ decision: 'skip-already-applied',
115
+ reason: `${input.phaseName} previously succeeded; all ${readbacksConsulted.length} platform ref(s) verified live — skip-already-applied`,
116
+ refsConsulted,
117
+ readbacksConsulted,
118
+ };
119
+ }
120
+ /** A readback is "valid" — i.e. authorizes a skip-already-applied — when the
121
+ * platform confirms the ref still exists AND its current state is one of
122
+ * the "still represents the prior side effect" set. The deny-set:
123
+ * - 'closed' / 'rolled-back' / 'failed' → side effect was reverted;
124
+ * replaying would create a new artifact.
125
+ * - 'unknown' → fail-closed; we can't make a confident assertion.
126
+ * Anything else (open / merged / live) is treated as "ref still represents
127
+ * the prior side effect" — replay would be a duplicate. */
128
+ function isReadbackValid(rb) {
129
+ if (!rb.existsOnPlatform)
130
+ return false;
131
+ switch (rb.currentState) {
132
+ case 'open':
133
+ case 'merged':
134
+ case 'live':
135
+ return true;
136
+ case 'closed':
137
+ case 'rolled-back':
138
+ case 'failed':
139
+ case 'unknown':
140
+ default:
141
+ return false;
142
+ }
143
+ }
144
+ //# sourceMappingURL=replay-decision.js.map