faberun 0.30.0 → 0.32.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 (60) hide show
  1. package/package.json +1 -1
  2. package/src/campaign/campaign-brief.mjs +17 -6
  3. package/src/campaign/chain.mjs +124 -8
  4. package/src/campaign/ledger.mjs +19 -7
  5. package/src/campaign/metrics-command.mjs +68 -9
  6. package/src/campaign/metrics.mjs +75 -45
  7. package/src/campaign/record.mjs +8 -1
  8. package/src/campaign/reserve.mjs +340 -0
  9. package/src/campaign/spend.mjs +229 -0
  10. package/src/campaign/unpark.mjs +54 -1
  11. package/src/campaign/watch.mjs +314 -37
  12. package/src/cli/brand.mjs +2 -1
  13. package/src/cli.mjs +52 -2
  14. package/src/contract/judge-envelope.mjs +17 -2
  15. package/src/contract/scope-findings.mjs +3 -1
  16. package/src/contract/task-packet.mjs +40 -5
  17. package/src/contract/verification.mjs +67 -1
  18. package/src/engine/aux-admission.mjs +124 -0
  19. package/src/engine/capacity.mjs +78 -0
  20. package/src/engine/dispatch.mjs +116 -23
  21. package/src/engine/failure-cause.mjs +59 -0
  22. package/src/engine/lifecycle.mjs +3 -2
  23. package/src/engine/phase-session.mjs +56 -10
  24. package/src/engine/prompts.mjs +57 -2
  25. package/src/engine/reserve-admission.mjs +99 -0
  26. package/src/engine/resume.mjs +3 -1
  27. package/src/engine/review.mjs +21 -0
  28. package/src/engine/run-command.mjs +166 -11
  29. package/src/engine/scheduler.mjs +92 -88
  30. package/src/engine/settle.mjs +15 -8
  31. package/src/engine/settlement-overlap.mjs +143 -0
  32. package/src/engine/verify.mjs +131 -7
  33. package/src/harnesses/session-metrics.mjs +112 -20
  34. package/src/notify/index.mjs +86 -10
  35. package/src/plan/freeze.mjs +7 -2
  36. package/src/plan/human-step.mjs +36 -0
  37. package/src/plan/pipeline.mjs +53 -22
  38. package/src/plan/preflight.mjs +81 -5
  39. package/src/plan/progress.mjs +101 -1
  40. package/src/plan/repo-facts.mjs +403 -49
  41. package/src/plan/resolve.mjs +7 -0
  42. package/src/plan/revise-context.mjs +108 -0
  43. package/src/plan/rounds.mjs +180 -45
  44. package/src/plan/sizing.mjs +33 -1
  45. package/src/plan/template.mjs +134 -22
  46. package/src/repo/integrate.mjs +47 -5
  47. package/src/report/campaign-brief-estimate.mjs +47 -1
  48. package/src/report/campaign-brief.mjs +31 -2
  49. package/src/report/locale.mjs +20 -0
  50. package/src/report/message.mjs +13 -1
  51. package/src/report/metrics-report.mjs +38 -0
  52. package/src/report/progress.mjs +194 -10
  53. package/src/report/render.mjs +1 -1
  54. package/src/report/report-html.mjs +235 -0
  55. package/src/report/report-video.mjs +323 -0
  56. package/src/run/usage-windows.mjs +99 -4
  57. package/src/run/usage.mjs +69 -7
  58. package/src/web/api.mjs +179 -24
  59. package/src/web/app.mjs +27 -2
  60. package/src/web/server.mjs +32 -0
@@ -5,6 +5,16 @@
5
5
  * freeze.mjs can carry it on the frozen plan as an explicit stop, and the
6
6
  * Campaign Brief can list it among the human decisions. Pure text matching:
7
7
  * no file I/O, no git, no model.
8
+ *
9
+ * The same module owns the other thing only an operator can do, because it
10
+ * is the same boundary at a different source: a packet refusal the failure
11
+ * classifier named `ambiguous_requirement` (R5, campaign-efficiency phase 4)
12
+ * is not repairable by widening the packet — the requirement itself can be
13
+ * read more than one way — so it becomes a durable question
14
+ * (`durableQuestion`) the run carries until `resume --answer <node>` closes
15
+ * it, instead of another widening attempt. The source is the run's failure
16
+ * journal, not the spec's constraints; the discipline is unchanged: pure
17
+ * record shaping, no file I/O, no git, no model.
8
18
  */
9
19
 
10
20
  /** @typedef {{requirementId: string, step: string, command: string}} HumanStep */
@@ -36,3 +46,29 @@ export function collectHumanSteps(requirements) {
36
46
  .map((requirement) => detectHumanStep(requirement))
37
47
  .filter((step) => step !== null);
38
48
  }
49
+
50
+ /** @typedef {{node: string, artifactVersion: string, question: string}} DurableQuestion */
51
+
52
+ /**
53
+ * The durable question an ambiguous-requirement refusal becomes. Null for
54
+ * every other cause: a read or write gap is the packet's own defect and a
55
+ * widening round can repair it, but an ambiguity is a property of the
56
+ * requirement the packet was written from, and no widening of read or write
57
+ * scope can answer it — another attempt would re-read the same prose.
58
+ *
59
+ * The question names the node and the packet version it refuses so the
60
+ * operator's answer is bound to the same key the failure journal uses: a
61
+ * widened packet changes the version, and with it the question.
62
+ *
63
+ * @param {{cause: string, node: string, artifactVersion: string}} record a
64
+ * failureCause record as the reauthor journal writes it
65
+ * @returns {DurableQuestion|null}
66
+ */
67
+ export function durableQuestion(record) {
68
+ if (record.cause !== "ambiguous_requirement") return null;
69
+ return {
70
+ node: record.node,
71
+ artifactVersion: record.artifactVersion,
72
+ question: `Node ${record.node} refused its packet at ${record.artifactVersion} with cause ambiguous_requirement: the requirement can be read more than one way, and no widening of the packet can answer it. Decide what the requirement means, then resume with --answer ${record.node}; no reauthor round and no further attempt runs on this packet version until then.`,
73
+ };
74
+ }
@@ -25,13 +25,12 @@ import { readCampaign } from "../campaign/record.mjs";
25
25
  import { campaignCli } from "../cli/campaign.mjs";
26
26
  import { appendJsonl } from "../run/store.mjs";
27
27
  import { allowanceDelta, allowanceEventFields, sampleAllowance } from "../seat/allowance.mjs";
28
- import { askPlanningRuntimes, refusePlanningSilence, refuseUnplannableRuntimes } from "./preflight.mjs";
28
+ import { askPlanningRuntimes, checkPlanBeforeReview, refusePlanningSilence, refuseUnplannableRuntimes } from "./preflight.mjs";
29
29
  import { validateJudgeIndependence } from "../contract/judge-independence.mjs";
30
30
  import { firstEligibleReviewer, reviewerProvenanceOf } from "./reviewer.mjs";
31
31
  import { parseSpec, validateSpec } from "./spec.mjs";
32
- import { collectRepoFacts } from "./repo-facts.mjs";
33
- import { clearPlanProgress, writePlanProgress } from "./progress.mjs";
34
- import { checkPlanProofs } from "./proof-check.mjs";
32
+ import { collectRepoFacts, persistRepoFacts } from "./repo-facts.mjs";
33
+ import { abandonedPlanProgress, clearPlanProgress, startDraftPlan, writePlanDraftRecord, writePlanProgress } from "./progress.mjs";
35
34
  import { collectHumanSteps } from "./human-step.mjs";
36
35
  import { RISK_TIERS, TASK_KIND_CATALOGUE_FILE, buildPlanningContract, renderTaskKindCatalogue, validatePlanOutput } from "./template.mjs";
37
36
  import { MIN_WRITE_FILES, applySizingRules, provenParallelism } from "./sizing.mjs";
@@ -159,6 +158,12 @@ async function runPlanningStages(options) {
159
158
  // the home, and the scratch relay inside the repository is excluded by the
160
159
  // same pathspec the launch uses.
161
160
  assertLaunchBaseClean(cwd, undefined);
161
+ // The refusal half of the pre-flight, decided from the catalogue and the
162
+ // machine alone and returned before anything is dispatched, so a runtime
163
+ // choice no frozen contract could carry costs no model call: it runs before
164
+ // the liveness ask below because that ask is itself a provider invocation,
165
+ // and everything a plan can still get wrong is a finding for the repair
166
+ // loop rather than a refusal.
162
167
  refuseUnplannableRuntimes(runtimes, runtimeDefaults, packageMode, judgeIndependence);
163
168
  refusePlanningSilence(await ask(runtimes, runtimeDefaults, cwd), cwd);
164
169
 
@@ -202,6 +207,8 @@ async function runPlanningStages(options) {
202
207
  // the draft stage reads what the planner measured instead of inferring it
203
208
  // from the spec's prose.
204
209
  const parsedSpec = parseSpec(specText);
210
+ // Sampled before this pipeline's own first liveness write (repo facts) overwrites it.
211
+ const abandoned = abandonedPlanProgress(cwd, campaignId, phase);
205
212
  // This stage holds the terminal for minutes, which is why it says what it is
206
213
  // about to do before it does it and one line per command as each finishes.
207
214
  // Measured 2026-09-25/26 (AP3): five to eight minutes with nothing written
@@ -217,8 +224,12 @@ async function runPlanningStages(options) {
217
224
  // here so both the freeze below and a caller inspecting the pipeline agree
218
225
  // on the same list.
219
226
  const humanSteps = collectHumanSteps(parsedSpec.requirements);
220
- const repoFactsPath = join(scratchDir, "repo-facts.json");
221
- writeFileSync(repoFactsPath, `${JSON.stringify(repoFacts, null, 2)}\n`);
227
+ // The draft and review stages read the cut through `readFiles`, and the
228
+ // complete path index is staged beside it rather than inside it: the
229
+ // artefact stays byte-bounded, and a discovery node explicitly authorised to
230
+ // enumerate the tree is handed `repo-paths.txt` instead of a cut that lies
231
+ // about what the tree holds (R4).
232
+ const repoFactsPath = persistRepoFacts(scratchDir, repoFacts, cwd);
222
233
  const relativeRepoFactsPath = relative(cwd, repoFactsPath);
223
234
  // The taskKind catalogue is staged like every other planning input. It used
224
235
  // to be handed over as faberun's own `src/plan/template.mjs`, which resolves
@@ -278,7 +289,13 @@ async function runPlanningStages(options) {
278
289
  return { contract: validated, output };
279
290
  };
280
291
 
281
- const draft = await runStage("draft", { specPath: relativeSpecPath, repoFactsPath: relativeRepoFactsPath, cataloguePath: relativeCataloguePath, packageMode });
292
+ // R2 resume: the sampled record survives only a process that died, and a
293
+ // dead attempt that validated its draft left writePlanDraftRecord's file
294
+ // behind. A finished phase — frozen or contested — has a plan.json, and that drafts fresh.
295
+ const draft = await startDraftPlan({
296
+ abandoned, plansDir, specDigest, runStage, logStage,
297
+ draftStageInput: { specPath: relativeSpecPath, repoFactsPath: relativeRepoFactsPath, cataloguePath: relativeCataloguePath, packageMode },
298
+ });
282
299
  /** @type {PlanOutput|null} */
283
300
  let plan = null;
284
301
  // Everything still open against the plan in hand, accumulated across rounds
@@ -288,23 +305,16 @@ async function runPlanningStages(options) {
288
305
  /** @type {PlanFindingOutput[]} */
289
306
  let findings = [];
290
307
  try {
291
- plan = validatePlanOutput(draft.output.plan);
308
+ plan = validatePlanOutput(draft.planOutput);
292
309
  } catch (error) {
293
310
  findings = [invalidPlanFinding("draft", error)];
294
311
  }
295
- logStage("draft", plan === null
296
- ? { runId: draft.contract.id, invalid: findings[0].text }
297
- : { runId: draft.contract.id, nodeCount: plan.nodes.length });
298
-
299
- // R15: a deterministic check for a DoD proof no node can satisfy, run once
300
- // against the drafted plan before the first review round grades it — a
301
- // finding this raises is exactly as actionable to a revise as a reviewer's
302
- // own, and raising it before review means review never spends a round
303
- // re-discovering what a mechanical check already knows for certain.
304
- if (plan !== null) {
305
- const proofFindings = checkPlanProofs(plan, repoFacts, cwd);
306
- if (proofFindings.length > 0) findings = [...findings, ...proofFindings];
307
- logStage("proof-check", { findingsCount: proofFindings.length });
312
+ if (!draft.resumed) {
313
+ // Durable before its stage line: a death in between leaves an adoptable draft.
314
+ if (plan !== null) writePlanDraftRecord(plansDir, { runId: draft.runId, at: new Date().toISOString(), specDigest, plan });
315
+ logStage("draft", plan === null
316
+ ? { runId: draft.runId, invalid: findings[0].text }
317
+ : { runId: draft.runId, nodeCount: plan.nodes.length });
308
318
  }
309
319
 
310
320
  const workingPlanPath = join(scratchDir, "plan.working.json");
@@ -364,8 +374,29 @@ async function runPlanningStages(options) {
364
374
  */
365
375
  const frozenContractRaw = (assembly) => frozenContractRawOf(assembly, { campaignId, phase, campaignGoal: campaign.goal, cwd, plansDir, runtimes, runtimeDefaults, judgeIndependence });
366
376
 
377
+ // R5: the complete deterministic check set — the freeze rules `freeze.mjs`
378
+ // and `proof-scope.mjs` apply, plus the plan-wide proof scan (R15) — runs
379
+ // here, before the first semantic review is dispatched, and its diagnostics
380
+ // are forwarded into the round loop as findings: a mechanical objection is
381
+ // then answered by the same revise a reviewer's objection is, inside the
382
+ // rounds `--review-rounds` already pays for, never by a refusal path of its
383
+ // own. The plan it hands on is the repaired one, so the artefact the
384
+ // reviewer reads is the artefact that freezes. A failure the freeze itself
385
+ // would raise is logged here and raised as a finding by the round loop's own
386
+ // copy of this check, in the same round and under the round's own id: one
387
+ // objection, one finding, one repair.
388
+ if (plan !== null) {
389
+ const checks = checkPlanBeforeReview(plan, { repoFacts, cwd, plansDir, assembleFrozenNodes, frozenContractRaw });
390
+ plan = checks.plan;
391
+ if (checks.raised.length > 0) logStage("timeouts-raised", { raised: checks.raised });
392
+ if (checks.declared.length > 0) logStage("guards-declared", { declared: checks.declared });
393
+ findings = [...findings, ...checks.findings];
394
+ logStage("proof-check", { findingsCount: checks.findings.length });
395
+ logStage("preflight", { raised: checks.raised.length, declared: checks.declared.length, ...(checks.failure === null ? {} : { failed: checks.failure instanceof Error ? checks.failure.message : String(checks.failure) }) });
396
+ }
397
+
367
398
  const roundsResult = await runReviewRounds({
368
- reviewRounds, plan, rejectedDraft: plan === null ? draft.output.plan : undefined, findings, cwd, plansDir, scratchDir, workingPlanPath, relativeWorkingPlanPath,
399
+ reviewRounds, plan, rejectedDraft: plan === null ? draft.planOutput : undefined, findings, cwd, plansDir, scratchDir, workingPlanPath, relativeWorkingPlanPath,
369
400
  relativeSpecPath, relativeRepoFactsPath, relativeCataloguePath, packageMode, repoFacts,
370
401
  runStage, assembleFrozenNodes, frozenContractRaw, contest,
371
402
  invalidPlanFinding, logStage,
@@ -1,17 +1,93 @@
1
1
  /**
2
- * The ask `faberun plan` makes before its first stage.
2
+ * What `faberun plan` decides before it spends anything: the refusals it
3
+ * returns before the first stage is dispatched -- which runtimes a planning run
4
+ * will spend, and what counts as one of them not answering -- and the
5
+ * deterministic checks a plan passes before any semantic review grades it.
3
6
  *
4
- * It lives beside the pipeline rather than inside it because asking is its own
5
- * concern -- which runtimes a planning run will spend, and what counts as one
6
- * of them not answering -- and because `pipeline.mjs` sits 45 lines from this
7
- * tree's 800-line ceiling.
7
+ * Both halves live beside the pipeline rather than inside it because asking
8
+ * and checking are their own concerns, and because `pipeline.mjs` sits close
9
+ * to this tree's 800-line ceiling.
8
10
  */
11
+ import { join } from "node:path";
9
12
  import { preflightRuntimes } from "../engine/live-preflight.mjs";
10
13
  import { liveSilenceCause } from "../engine/live-silence.mjs";
11
14
  import { harnessCapabilities } from "../harnesses/index.mjs";
15
+ import { validateContract } from "../contract/index.mjs";
12
16
  import { assertRuntimeExecutesCommands, validateRuntime } from "../contract/runtime.mjs";
13
17
  import { effectiveProvider } from "../contract/provider.mjs";
14
18
  import { SAME_VENDOR_REVIEW_MODE, sameVendorTierRefusal } from "../contract/judge-independence.mjs";
19
+ import { assertTimeoutsCoverMeasured, raiseTimeoutsToMeasured } from "./freeze.mjs";
20
+ import { assertFilteredProofsNameTheirTest, declareDirectoryGuards } from "./proof-scope.mjs";
21
+ import { checkPlanProofs } from "./proof-check.mjs";
22
+
23
+ /** @typedef {import("./template.mjs").PlanOutput} PlanOutput */
24
+ /** @typedef {import("./template.mjs").PlanFindingOutput} PlanFindingOutput */
25
+ /** @typedef {import("../contract/index.mjs").JsonObject} JsonObject */
26
+ /** @typedef {import("./pipeline.mjs").AssembledPlan} AssembledPlan */
27
+
28
+ /**
29
+ * What the deterministic checks need: the measured repository facts the freeze
30
+ * rules read, the cwd and plansDir the contract is assembled against, and the
31
+ * two closures that render the plan the way `freezePlan` will. They are
32
+ * supplied rather than imported because they are the pipeline's own assembly
33
+ * (`pipeline.mjs` imports this module), and taking them as data is what lets
34
+ * `rounds.mjs` — and a test — run the checks with fakes.
35
+ *
36
+ * @typedef {{repoFacts: import("./repo-facts.mjs").RepoFacts, cwd: string, plansDir: string, assembleFrozenNodes: (plan: PlanOutput) => AssembledPlan, frozenContractRaw: (assembly: AssembledPlan) => JsonObject}} PlanCheckContext
37
+ */
38
+
39
+ /**
40
+ * The two mechanically repairable freeze rules, applied, then the freeze
41
+ * contract itself validated the way `freeze.mjs` and `proof-scope.mjs`
42
+ * validate it: the rules are imported, never restated, so this cannot drift
43
+ * from the freeze it stands in front of.
44
+ *
45
+ * It does not throw. A plan the checks refuse is a diagnostic for the repair
46
+ * loop, not a refusal: the refusal is the pre-dispatch half of this module,
47
+ * and a thrown plan check would spend a round's budget twice — once on the
48
+ * revise that answers a finding, and once on the error that killed it.
49
+ *
50
+ * The failure is returned beside the repaired plan rather than instead of it,
51
+ * so the caller can hand the plan on to a review (or to the freeze) while
52
+ * still recording what the freeze would have said.
53
+ *
54
+ * @param {PlanOutput} plan
55
+ * @param {PlanCheckContext} ctx
56
+ * @returns {{plan: PlanOutput, raised: string[], declared: string[], failure: unknown|null}}
57
+ */
58
+ export function freezePreflight(plan, ctx) {
59
+ const timeouts = raiseTimeoutsToMeasured(plan, ctx.repoFacts);
60
+ const guards = declareDirectoryGuards(timeouts.plan, ctx.repoFacts, ctx.cwd);
61
+ const candidate = guards.plan;
62
+ const repaired = { plan: candidate, raised: timeouts.raised, declared: guards.declared };
63
+ try {
64
+ const contract = validateContract(ctx.frozenContractRaw(ctx.assembleFrozenNodes(candidate)), join(ctx.plansDir, "contract.json"));
65
+ assertFilteredProofsNameTheirTest(contract);
66
+ assertTimeoutsCoverMeasured(contract, ctx.repoFacts);
67
+ return { ...repaired, failure: null };
68
+ } catch (error) {
69
+ return { ...repaired, failure: error };
70
+ }
71
+ }
72
+
73
+ /**
74
+ * The complete deterministic check set a plan passes before any semantic
75
+ * review begins: `freezePreflight` plus the plan-wide proof scan, whose two
76
+ * shapes (a name filter that selects no test, a bare grep proving an absence)
77
+ * no reviewer decides better than a local read. The pipeline runs this once on
78
+ * the draft and forwards `findings` into the round loop's own repair budget —
79
+ * the rounds `--review-rounds` already pays for — while the loop runs
80
+ * `freezePreflight` on every candidate it grades, where the repair it drives
81
+ * is the answer to whatever the check refused.
82
+ *
83
+ * @param {PlanOutput} plan
84
+ * @param {PlanCheckContext} ctx
85
+ * @returns {{plan: PlanOutput, raised: string[], declared: string[], findings: PlanFindingOutput[], failure: unknown|null}}
86
+ */
87
+ export function checkPlanBeforeReview(plan, ctx) {
88
+ const checks = freezePreflight(plan, ctx);
89
+ return { ...checks, findings: checkPlanProofs(checks.plan, ctx.repoFacts, ctx.cwd) };
90
+ }
15
91
 
16
92
  /**
17
93
  * The runtimes a planning run will spend, asked once before its first stage.
@@ -19,12 +19,24 @@
19
19
  * also why `plan --resolve` writes none: it is never detached, so a death
20
20
  * there is visible where it happens and a record would only add a second
21
21
  * account of something the operator already watched.
22
+ *
23
+ * The module also holds the record of what a dead attempt finished:
24
+ * `writePlanDraftRecord` persists the validated draft plan beside the phase's
25
+ * plan artifacts, and `resumablePlanDraft` reads it back for a resumed
26
+ * attempt, because the liveness record says that the attempt died but not
27
+ * what its death had already paid for. `startDraftPlan` decides, for one
28
+ * attempt, between adopting that draft and dispatching the draft stage —
29
+ * dispatching stays the pipeline's verb (it is passed in), but whether to
30
+ * dispatch at all is this module's question. Same boundary, same reader —
31
+ * all of it answers a later launch's one question, what did the last attempt
32
+ * leave behind.
22
33
  */
23
- import { rmSync } from "node:fs";
34
+ import { existsSync, rmSync } from "node:fs";
24
35
  import { join } from "node:path";
25
36
  import { pidAlive, processStartToken, sameProcessStartToken } from "../run/lock.mjs";
26
37
  import { campaignTree } from "../run/paths.mjs";
27
38
  import { readJson, writeJsonAtomic } from "../run/store.mjs";
39
+ import { validatePlanOutput } from "./template.mjs";
28
40
 
29
41
  /** @typedef {{pid?: number, processStartToken?: string|null, at?: string, stage?: string, [key: string]: unknown}} PlanProgressRecord */
30
42
 
@@ -99,3 +111,91 @@ export function abandonedPlanProgress(cwd, campaignId, phase) {
99
111
  export function clearPlanProgress(cwd, campaignId, phase) {
100
112
  rmSync(planProgressPath(cwd, campaignId, phase), { force: true });
101
113
  }
114
+
115
+ /** The file beside a phase's plan artifacts holding the draft a resumed attempt can adopt. */
116
+ const PLAN_DRAFT_FILE = "draft.json";
117
+
118
+ /**
119
+ * The validated draft a later attempt can adopt without paying for it again.
120
+ *
121
+ * @typedef {{runId: string, at: string, specDigest: string, plan: import("./template.mjs").PlanOutput}} PlanDraftRecord
122
+ */
123
+
124
+ /**
125
+ * Persist the validated draft plan where a later launch of the same phase
126
+ * finds it: beside the phase's durable plan artifacts, never in the disposable
127
+ * scratch tree, for the same reason the progress record is there — a record a
128
+ * later launch has to find cannot live where the next `plan` may wipe. The
129
+ * record is written once, when the draft validates, and never removed: a
130
+ * frozen or contested phase writes `plan.json` beside it, and the presence of
131
+ * that file is what stops a later attempt from adopting anything.
132
+ *
133
+ * @param {string} plansDir
134
+ * @param {PlanDraftRecord} record
135
+ * @returns {void}
136
+ */
137
+ export function writePlanDraftRecord(plansDir, record) {
138
+ writeJsonAtomic(join(plansDir, PLAN_DRAFT_FILE), record);
139
+ }
140
+
141
+ /**
142
+ * The dead attempt's draft, when adopting it costs no paid call, or null when
143
+ * there is nothing to adopt. Two records must agree before anything is
144
+ * adopted: `abandoned` — the progress record above, sampled by the caller
145
+ * before its own first liveness write, which would otherwise overwrite the
146
+ * evidence of the death it is about to resume from — and the draft record
147
+ * `writePlanDraftRecord` wrote when the dead attempt validated its draft. The
148
+ * plan in it is exactly the paid work a resumed attempt would otherwise
149
+ * duplicate. A phase that ever finished, frozen or contested, has a
150
+ * `plan.json` beside the record: that file, not the record's absence, is
151
+ * what makes a later deliberate re-plan draft fresh. A record from a
152
+ * different spec (the operator edited the spec between attempts) or one
153
+ * whose plan no longer validates is refused the same way.
154
+ *
155
+ * @param {{abandoned: PlanProgressRecord|null, plansDir: string, specDigest: string}} options
156
+ * @returns {{runId: string, plan: import("./template.mjs").PlanOutput}|null}
157
+ */
158
+ export function resumablePlanDraft({ abandoned, plansDir, specDigest }) {
159
+ if (abandoned === null) return null;
160
+ if (existsSync(join(plansDir, "plan.json"))) return null;
161
+ let record;
162
+ try {
163
+ record = /** @type {PlanDraftRecord|unknown} */ (readJson(join(plansDir, PLAN_DRAFT_FILE)));
164
+ } catch {
165
+ // An absent record is the ordinary case: a draft still running, or a
166
+ // death before the draft validated, leaves nothing to adopt.
167
+ return null;
168
+ }
169
+ if (!record || typeof record !== "object" || Array.isArray(record)) return null;
170
+ const draft = /** @type {PlanDraftRecord} */ (record);
171
+ if (draft.specDigest !== specDigest || typeof draft.runId !== "string") return null;
172
+ try {
173
+ return { runId: draft.runId, plan: validatePlanOutput(draft.plan) };
174
+ } catch {
175
+ // The record's plan no longer validates: adopting it would spend the
176
+ // round budget repairing an unknown shape, so the caller drafts fresh.
177
+ return null;
178
+ }
179
+ }
180
+
181
+ /**
182
+ * The draft one attempt starts from: the dead attempt's validated plan when
183
+ * adopting it costs no paid call, or a fresh dispatch of the draft stage.
184
+ * The adoption is announced as its own stage line — a resumed attempt that
185
+ * dispatched nothing must still leave the same record a drafting one does.
186
+ *
187
+ * @param {{abandoned: PlanProgressRecord|null, plansDir: string, specDigest: string,
188
+ * draftStageInput: Record<string, unknown>,
189
+ * runStage: (kind: import("./template.mjs").PlanningKind, input: Record<string, unknown>) => Promise<{contract: {id: string}, output: Record<string, unknown>}>,
190
+ * logStage: (stage: string, detail: Record<string, unknown>) => void}} options
191
+ * @returns {Promise<{runId: string, planOutput: unknown, resumed: boolean}>}
192
+ */
193
+ export async function startDraftPlan({ abandoned, plansDir, specDigest, draftStageInput, runStage, logStage }) {
194
+ const resumed = resumablePlanDraft({ abandoned, plansDir, specDigest });
195
+ if (resumed !== null) {
196
+ logStage("draft-resumed", { runId: resumed.runId, nodeCount: resumed.plan.nodes.length });
197
+ return { runId: resumed.runId, planOutput: resumed.plan, resumed: true };
198
+ }
199
+ const stage = await runStage("draft", draftStageInput);
200
+ return { runId: stage.contract.id, planOutput: stage.output.plan, resumed: false };
201
+ }