omp-conductor 0.18.2 → 0.19.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/README.md +105 -40
  2. package/REFERENCE.md +865 -30
  3. package/package.json +1 -1
  4. package/schema/config.schema.json +26 -0
  5. package/src/admission.ts +212 -26
  6. package/src/ask.ts +288 -1
  7. package/src/briefs/orchestrator.md +6 -5
  8. package/src/cli.ts +5 -1
  9. package/src/command-help.ts +9 -1
  10. package/src/command-manifest.ts +36 -3
  11. package/src/commands/arm.ts +5 -1
  12. package/src/commands/context.ts +2 -0
  13. package/src/commands/message.ts +26 -2
  14. package/src/commands/reconcile-units.ts +104 -0
  15. package/src/commands/release-composition.ts +232 -0
  16. package/src/commands/resume.ts +2 -27
  17. package/src/commands/setup.ts +101 -16
  18. package/src/commands/stats.ts +11 -30
  19. package/src/commands/tail.ts +31 -1
  20. package/src/commands/upgrade.ts +20 -3
  21. package/src/commands/verb.ts +2 -1
  22. package/src/config-schema.ts +19 -0
  23. package/src/config.ts +80 -0
  24. package/src/credential-class.ts +366 -0
  25. package/src/daemon.ts +1218 -288
  26. package/src/dashboard/app.js +504 -2
  27. package/src/dashboard/controls.ts +336 -0
  28. package/src/dashboard/index.html +30 -0
  29. package/src/dashboard/server.ts +271 -30
  30. package/src/dashboard/style.css +116 -0
  31. package/src/dashboard/transcript.ts +173 -0
  32. package/src/doctor.ts +377 -20
  33. package/src/failure-class.ts +59 -0
  34. package/src/fleet.ts +497 -15
  35. package/src/host.ts +6 -130
  36. package/src/omp.ts +29 -0
  37. package/src/orchestrator-tick.ts +343 -88
  38. package/src/pause.ts +233 -0
  39. package/src/settlement.ts +159 -2
  40. package/src/setup-answers.ts +97 -0
  41. package/src/setup-host.ts +321 -1155
  42. package/src/setup-install.ts +204 -27
  43. package/src/setup-wizard.ts +111 -50
  44. package/src/setup.ts +33 -0
  45. package/src/spend-telemetry.ts +117 -0
  46. package/src/stats.ts +35 -0
  47. package/src/status-render.ts +348 -19
  48. package/src/store.ts +1229 -55
  49. package/src/telegram-freshness.ts +269 -0
  50. package/src/to-spec.ts +27 -0
  51. package/src/types.ts +697 -4
  52. package/src/unblock.ts +22 -0
  53. package/src/unit-reconcile.ts +303 -0
  54. package/src/upgrade-verify.ts +8 -1
  55. package/src/upgrade.ts +299 -12
  56. package/src/verbs/actions.ts +124 -10
  57. package/src/verbs/protocol.ts +70 -2
  58. package/src/verbs/server.ts +447 -8
  59. package/src/wake.ts +48 -0
  60. package/src/worker.ts +403 -3
package/src/pause.ts ADDED
@@ -0,0 +1,233 @@
1
+ /**
2
+ * The dispatch pause sentinel: one file per project (plus the legacy bare one),
3
+ * its provenance line, and the reads that judge it.
4
+ *
5
+ * A leaf module on purpose. `daemon.ts` owns dispatch and imports `doctor.ts`,
6
+ * so `doctor.ts` cannot import `daemon.ts` back — yet a fence is exactly the
7
+ * kind of state a health check has to read (#938). Both import this instead,
8
+ * and `daemon.ts` re-exports every name so no existing call site moves.
9
+ *
10
+ * Nothing here decides policy: it reads and writes the file honestly, and an
11
+ * unreadable or malformed sentinel is `undefined` so every caller keeps failing
12
+ * closed rather than inferring "no pause".
13
+ */
14
+
15
+ import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
16
+ import { dirname, join } from "node:path";
17
+ import { stateDir } from "./config.ts";
18
+
19
+ /**
20
+ * The sentinel's provenance line, in one place.
21
+ *
22
+ * It was written twice — here and as a copy in the setup wizard's
23
+ * `isValidPauseBytes`, whose comment claimed to be "the exact grammar
24
+ * `pauseInstance` reads". Adding `owner=` (#938) proved the hazard rather than
25
+ * describing it: the copy did not know the new field, so a setup fence read as
26
+ * *malformed* to the transaction that had just written it, and the refusal
27
+ * path then declined to remove its own hold. One export, no copies.
28
+ */
29
+ const PROVENANCE_LINE = /^source=(\S+)(?: reason="([^"]*)")?(?: owner=(\d+))?$/;
30
+
31
+ /**
32
+ * Whether these bytes are a readable pause sentinel: a parseable ISO instant,
33
+ * then a provenance line. Exported so no caller has to restate the grammar to
34
+ * tell a hold it may restore from one it must never delete.
35
+ */
36
+ export function pauseBytesValid(bytes: string): boolean {
37
+ const [line1, line2] = bytes.split("\n");
38
+ if (!Number.isFinite(Date.parse(line1?.trim() ?? ""))) return false;
39
+ if (line2 === undefined) return false;
40
+ return PROVENANCE_LINE.test(line2.trim());
41
+ }
42
+
43
+ /**
44
+ * Pause is a file rather than process state on purpose: `omp-conductor hold`
45
+ * runs in a different process from the daemon, and a flag
46
+ * on disk needs no IPC and survives a restart. A daemon that crashed while
47
+ * paused comes back paused.
48
+ */
49
+ export function pausedPath(project?: string): string {
50
+ return join(stateDir(), project === undefined ? "paused" : `paused-${project}`);
51
+ }
52
+
53
+ function activePausePaths(project?: string): string[] {
54
+ const paths = project === undefined ? [pausedPath()] : [pausedPath(project), pausedPath()];
55
+ return paths.filter((path) => existsSync(path));
56
+ }
57
+
58
+ export function isPaused(project?: string): boolean {
59
+ return activePausePaths(project).length !== 0;
60
+ }
61
+
62
+ /**
63
+ * The epoch-ms timestamp at which the current pause began. A project pause also
64
+ * observes the legacy bare sentinel, which pauses every project. If any active
65
+ * sentinel is unreadable or unparseable, the timestamp is unknown so callers
66
+ * continue to fail closed.
67
+ */
68
+ export function pausedAt(project?: string): number | undefined {
69
+ const paths = activePausePaths(project);
70
+ if (paths.length === 0) return undefined;
71
+ const times: number[] = [];
72
+ for (const path of paths) {
73
+ try {
74
+ const first = readFileSync(path, "utf8").split("\n")[0]?.trim();
75
+ if (first === undefined || first === "") return undefined;
76
+ const time = Date.parse(first);
77
+ if (Number.isNaN(time)) return undefined;
78
+ times.push(time);
79
+ } catch {
80
+ return undefined;
81
+ }
82
+ }
83
+ return Math.min(...times);
84
+ }
85
+
86
+ /**
87
+ * Who paused the project and why. Per-project provenance wins when both its
88
+ * sentinel and the legacy all-project sentinel are active.
89
+ */
90
+ export function pauseProvenance(
91
+ project?: string,
92
+ ): { source: string; reason?: string; owner?: number } | undefined {
93
+ const paths =
94
+ project === undefined
95
+ ? [pausedPath()]
96
+ : [pausedPath(project), pausedPath()];
97
+ const path = paths.find((candidate) => existsSync(candidate));
98
+ if (path === undefined) return undefined;
99
+ try {
100
+ const second = readFileSync(path, "utf8").split("\n")[1];
101
+ if (second === undefined) return undefined;
102
+ const match = PROVENANCE_LINE.exec(second.trim());
103
+ if (match === null) return undefined;
104
+ const source = match[1]!;
105
+ const reason = match[2];
106
+ const owner = match[3] === undefined ? undefined : Number(match[3]);
107
+ return {
108
+ source,
109
+ ...(reason === undefined ? {} : { reason }),
110
+ ...(owner === undefined ? {} : { owner }),
111
+ };
112
+ } catch {
113
+ return undefined;
114
+ }
115
+ }
116
+
117
+ /**
118
+ * One pause sentinel FILE read as an instance identity: who set it, why, and
119
+ * the creation instant, all from that exact file. An unreadable or malformed
120
+ * file is undefined — the caller may treat it as absence.
121
+ */
122
+ export function pauseInstanceAt(
123
+ path: string,
124
+ ): { source: string; reason?: string; since: number; owner?: number } | undefined {
125
+ try {
126
+ const [line1, line2] = readFileSync(path, "utf8").split("\n");
127
+ const since = Date.parse(line1?.trim() ?? "");
128
+ if (!Number.isFinite(since)) return undefined;
129
+ if (line2 === undefined) return undefined;
130
+ const match = PROVENANCE_LINE.exec(line2.trim());
131
+ if (match === null) return undefined;
132
+ const source = match[1]!;
133
+ const reason = match[2];
134
+ const owner = match[3] === undefined ? undefined : Number(match[3]);
135
+ return {
136
+ source,
137
+ since,
138
+ ...(reason === undefined ? {} : { reason }),
139
+ ...(owner === undefined ? {} : { owner }),
140
+ };
141
+ } catch {
142
+ return undefined;
143
+ }
144
+ }
145
+
146
+ /**
147
+ * One pause sentinel read as a single identity: who set it, why, and the
148
+ * creation instant, all from the SAME file that was selected. Unlike pairing
149
+ * {@link pauseProvenance} with {@link pausedAt} — which can describe
150
+ * different files when a project pause coexists with the legacy global
151
+ * sentinel, letting a stale global timestamp mask a recreated project pause —
152
+ * this reads provenance and timestamp from one sentinel, so a caller can prove
153
+ * "the pause I set still exists" instead of "some pause with the same labels
154
+ * still exists" (#377). Per-project sentinel wins, like {@link pauseProvenance}.
155
+ */
156
+ export function pauseInstance(
157
+ project?: string,
158
+ ): { source: string; reason?: string; since: number; owner?: number } | undefined {
159
+ const paths =
160
+ project === undefined
161
+ ? [pausedPath()]
162
+ : [pausedPath(project), pausedPath()];
163
+ const path = paths.find((candidate) => existsSync(candidate));
164
+ if (path === undefined) return undefined;
165
+ return pauseInstanceAt(path);
166
+ }
167
+
168
+ /**
169
+ * Compare-and-clear one pause sentinel (#780 review): remove `path` only while
170
+ * it still holds exactly the `expected` instance — same source, same reason,
171
+ * same creation instant — as read by {@link pauseInstance}. A hold or pause
172
+ * that replaced or recreated the sentinel between the read and the clear is a
173
+ * newer instance (writes always re-stamp `since`), so it is never destroyed:
174
+ * returning false keeps the newer fence in force. Scoped strictly to one
175
+ * caller-provided path, so auto-expiry can clear the per-project spend-cap
176
+ * sentinel without ever touching the legacy global sentinel.
177
+ */
178
+ export function clearPauseIfUnchanged(
179
+ path: string,
180
+ expected: { source: string; reason?: string; since: number },
181
+ ): boolean {
182
+ const current = pauseInstanceAt(path);
183
+ if (current === undefined) return false;
184
+ if (
185
+ current.source !== expected.source ||
186
+ current.since !== expected.since ||
187
+ current.reason !== expected.reason
188
+ ) {
189
+ return false;
190
+ }
191
+ rmSync(path, { force: true });
192
+ return true;
193
+ }
194
+
195
+ /**
196
+ * The pause sentinel's `source=` token, proven from a verb. The sentinel's
197
+ * source line is read back as a single `\S+` token (see {@link pauseInstance}
198
+ * and {@link pauseProvenance}), so a verb that contains a space (`setup host`)
199
+ * is unrepresentable verbatim and must be encoded before it reaches disk —
200
+ * otherwise the fence cannot prove its own pause and refuses forever (#552).
201
+ * Spaces become `-`; the human-readable verb is preserved in the sentinel's
202
+ * `reason=` instead.
203
+ */
204
+ export function pauseSourceToken(verb: string): string {
205
+ return verb.trim().replace(/\s+/g, "-");
206
+ }
207
+
208
+ export function setPaused(
209
+ v: boolean,
210
+ why?: { source: string; reason?: string; owner?: number },
211
+ project?: string,
212
+ ): void {
213
+ const path = pausedPath(project);
214
+ if (v) {
215
+ mkdirSync(dirname(path), { recursive: true });
216
+ const line1 = `${new Date().toISOString()}\n`;
217
+ if (why === undefined) {
218
+ writeFileSync(path, line1);
219
+ } else {
220
+ const reason = why.reason === undefined ? "" : ` reason="${why.reason.replaceAll('"', "")}"`;
221
+ // `owner=` is written ONLY by a fence whose lifetime is a process's
222
+ // (#938): a `setup` apply holds dispatch across its own transaction, and
223
+ // when that process is killed nothing runs to lift it. An operator
224
+ // `hold` deliberately records none — it outlives every process by
225
+ // design, and a pid there would invite exactly the wrong inference.
226
+ const owner = why.owner === undefined ? "" : ` owner=${why.owner}`;
227
+ writeFileSync(path, `${line1}source=${why.source}${reason}${owner}\n`);
228
+ }
229
+ } else {
230
+ rmSync(path, { force: true });
231
+ if (project !== undefined) rmSync(pausedPath(), { force: true });
232
+ }
233
+ }
package/src/settlement.ts CHANGED
@@ -22,7 +22,7 @@ import {
22
22
  analyseSettlement,
23
23
  deriveChangedLine,
24
24
  } from "./diff-flags.ts";
25
- import { classifyRun, normalise, type ClassifyFacts } from "./failure-class.ts";
25
+ import { SPINNING_CAP_CLASSES, classifyRun, normalise, type ClassifyFacts } from "./failure-class.ts";
26
26
  import { GhPrMissingError } from "./tracker/github.ts";
27
27
  import { formatModelsTried, modelsTried } from "./model-fallback.ts";
28
28
  import { PR_LOOKUP_WINDOW_MS } from "./decisions.ts";
@@ -785,6 +785,55 @@ export async function reconcileStaleLabels(d: Pick<SettlementDeps, "project" | "
785
785
  }
786
786
  }
787
787
 
788
+ /**
789
+ * Retire grooming verdicts whose issue has closed (#964).
790
+ *
791
+ * The sibling above says it for labels: *never retain an `agent:*` label on a
792
+ * closed issue — the work is done by some route, and the label only makes the
793
+ * board lie about it.* A grooming verdict on a closed issue lies in exactly the
794
+ * same way, one surface over, and nothing retired one: `reconcileGrooming`
795
+ * clears only the two per-pass admission holds (`file-lane`, `depends-on`), so
796
+ * `promotable`, `considered`, `needs-product-decision` and in-flight batch
797
+ * markers accumulated for the life of the store.
798
+ *
799
+ * Measured on this fleet, 2026-08-23: 64 of 65 rows named a closed issue, all 26
800
+ * `promotable` rows did, and `status` therefore advertised 26 candidates ready
801
+ * to promote on a repo whose only open issue was one blocked on an operator.
802
+ *
803
+ * Deliberately **not** folded into `reconcileStaleLabels`, though the reasoning
804
+ * is its sibling's: that function iterates issues *carrying a label*, and a
805
+ * promotable issue carries none — not the queue label (promotion is what adds
806
+ * it) and no state label. It would have seen almost none of these rows.
807
+ *
808
+ * `listOpenIssues` is the required input and the reason this is safe: it is
809
+ * complete (paginated to the end), conditional (an unchanged repo costs a 304),
810
+ * and it *throws* rather than returning a short list. A label-filtered read here
811
+ * would condemn every verdict it failed to mention.
812
+ */
813
+ export async function reconcileGroomingClosures(
814
+ d: Pick<SettlementDeps, "project" | "tracker" | "store">,
815
+ ): Promise<void> {
816
+ const { project, tracker, store } = d;
817
+ let open: readonly { number: number }[];
818
+ try {
819
+ open = await tracker.listOpenIssues();
820
+ } catch (err) {
821
+ // Fail closed, and loudly enough to notice: retiring on an unread tracker
822
+ // would delete the whole table the first time GitHub rate-limited us.
823
+ log(`grooming closure reconcile skipped (${errText(err)}) — retrying next tick`);
824
+ return;
825
+ }
826
+ const retired = store.retireGroomingNotOpen(
827
+ project.name,
828
+ open.map((issue) => issue.number),
829
+ );
830
+ if (retired.length === 0) return;
831
+ log(
832
+ `grooming reconciled: ${retired.length} verdict(s) retired for closed issues ` +
833
+ `(${retired.slice(0, 8).map((n) => `#${n}`).join(", ")}${retired.length > 8 ? ", …" : ""})`,
834
+ );
835
+ }
836
+
788
837
  /**
789
838
  * Settles `claimed`/`running` rows left by a dead daemon process and, before
790
839
  * marking each one `orphaned`, salvages any dirty worktree.
@@ -1669,7 +1718,8 @@ async function recoverRun(
1669
1718
 
1670
1719
  if (recovery === "escalate") {
1671
1720
  const detail = [evidence];
1672
- if (cls === "turn-cap-spinning" || cls === "wall-clock-cap-spinning") {
1721
+ const spinning = SPINNING_CAP_CLASSES.includes(cls);
1722
+ if (spinning) {
1673
1723
  const calls = lastToolCalls(run.sessionFile);
1674
1724
  detail.push(
1675
1725
  calls.length === 0
@@ -1687,6 +1737,16 @@ async function recoverRun(
1687
1737
  ? `Session: ${run.sessionFile}`
1688
1738
  : `Session: ${run.sessionFile} (file was never written — the run died before its transcript was flushed)`,
1689
1739
  );
1740
+ // #807: a chain's first artifact-free cap kill is retried once on the
1741
+ // project's stronger tier instead of reaching a human — the capability
1742
+ // recovery the operator asked for. Everything else (no target, a second
1743
+ // cap, a spent continuation budget) falls through and escalates exactly as
1744
+ // it always has, saying in the page why no retry happened.
1745
+ if (spinning) {
1746
+ const outcome = await escalateModelTier(d, run, cls, evidence, detail);
1747
+ if (outcome.requeued) return;
1748
+ detail.push(outcome.note);
1749
+ }
1690
1750
  // The class and the run are in the summary, which is what the notifications
1691
1751
  // ledger dedupes on — so one class escalates once per run rather than every
1692
1752
  // five minutes.
@@ -1710,6 +1770,103 @@ async function recoverRun(
1710
1770
  // worktree holds the only copy of real work.
1711
1771
  }
1712
1772
 
1773
+ /**
1774
+ * The one model escalation an issue chain may spend (#807).
1775
+ *
1776
+ * A run that reached a ceiling with nothing to show is usually a slice that was
1777
+ * too big — which is why the cap classes are excluded from the provider
1778
+ * failover chain and why a second cap stays a decomposition verdict. But the
1779
+ * first one is worth one cheap experiment: the operator configures one stronger
1780
+ * opaque selector, and this hands the chain back to the queue to spend it,
1781
+ * instead of paging a human to do the same thing by hand.
1782
+ *
1783
+ * The marker, the queue hand-back and the row's recovery stamp are one store
1784
+ * transaction (`escalateModel`), so a daemon that dies mid-recovery restarts
1785
+ * into one of exactly two states: nothing happened and the next sweep escalates
1786
+ * cleanly, or all of it happened and the continuation is already owed. There is
1787
+ * no third state where the chain reads as escalated with the continuation it
1788
+ * promised never queued. Every refusal returns a note instead of a silent
1789
+ * fall-through: the page that follows says why no retry happened.
1790
+ *
1791
+ * Deliberately independent of `modelFallbacks`: nothing here reads or advances
1792
+ * the provider chain, and nothing there reads this marker, so a chain that
1793
+ * mixes provider aborts with a cap kill keeps both streaks intact.
1794
+ */
1795
+ async function escalateModelTier(
1796
+ d: SettlementDeps,
1797
+ run: RunRecord,
1798
+ cls: FailureClass,
1799
+ evidence: string,
1800
+ detail: readonly string[],
1801
+ ): Promise<{ requeued: true } | { requeued: false; note: string }> {
1802
+ const { project, caps, tracker, store } = d;
1803
+ const target = project.workerEscalationModel;
1804
+ if (target === undefined) {
1805
+ return {
1806
+ requeued: false,
1807
+ note:
1808
+ "No model escalation target exists: this project sets no `workerEscalationModel`, " +
1809
+ "so the cap reaches a human exactly as it did before.",
1810
+ };
1811
+ }
1812
+ const spent = store.modelEscalation(project.name, run.issue);
1813
+ if (spent !== undefined) {
1814
+ return {
1815
+ requeued: false,
1816
+ note:
1817
+ `This chain already spent its one model escalation (${spent.model}, bought by ${spent.failureClass}). ` +
1818
+ "A second cap is a decomposition verdict, not a third tier — re-scope the issue.",
1819
+ };
1820
+ }
1821
+ // The same two guards the wall-clock continuation applies before handing a
1822
+ // queue label back: a closed issue was resolved by another route, and a spent
1823
+ // continuation budget would offer a candidate admission can never accept
1824
+ // (#490, #348).
1825
+ const state = await tracker.issueState(run.issue).catch(() => undefined);
1826
+ if (state !== "open") {
1827
+ return { requeued: false, note: `Not retried on ${target}: the issue is ${state ?? "unreadable"}.` };
1828
+ }
1829
+ const continuations = store.continuationsFor(project.name, run.issue);
1830
+ if (!hasContinuationBudget(continuations, caps.maxContinuationsPerIssue)) {
1831
+ return {
1832
+ requeued: false,
1833
+ note:
1834
+ `Not retried on ${target}: ${continuations} continuation(s) are already charged ` +
1835
+ `against a cap of ${caps.maxContinuationsPerIssue}.`,
1836
+ };
1837
+ }
1838
+ const spending = store.escalateModel({
1839
+ project: project.name,
1840
+ issue: run.issue,
1841
+ model: target,
1842
+ failureClass: cls,
1843
+ runId: run.id,
1844
+ swapFrom: project.stateLabels.failed,
1845
+ queueLabel: project.queueLabel,
1846
+ });
1847
+ if (!spending) {
1848
+ return {
1849
+ requeued: false,
1850
+ note: `Not retried on ${target}: this chain's escalation was spent concurrently.`,
1851
+ };
1852
+ }
1853
+ await safeEscalate(d, {
1854
+ tier: 1,
1855
+ project: project.name,
1856
+ issue: run.issue,
1857
+ runId: run.id,
1858
+ summary: `[${cls}] #${run.issue} attempt ${run.attempt} — retrying once on ${target}: ${evidence}`,
1859
+ detail: [
1860
+ ...detail,
1861
+ "",
1862
+ `The queue label is back on and the next dispatch runs on ${target}, this chain's one`,
1863
+ "model escalation. Another cap after it is a decomposition verdict, not a third tier.",
1864
+ ].join("\n"),
1865
+ });
1866
+ log(`#${run.issue} requeued on ${target} after ${cls}: ${evidence}`);
1867
+ return { requeued: true };
1868
+ }
1869
+
1713
1870
  /**
1714
1871
  * Enqueue a state-label → queue-label swap for projection (#201).
1715
1872
  *
@@ -112,6 +112,103 @@ export function saveAnswersFile(path: string, answers: SetupAnswerValues): void
112
112
  writeFileSync(path, `${JSON.stringify(answers, null, 2)}\n`, "utf8");
113
113
  }
114
114
 
115
+ /**
116
+ * The resumable setup transaction (#864).
117
+ *
118
+ * On 2026-08-21 `setup policy` failed at its post-interview gates several times
119
+ * and each retry discarded the confirmed answers: the same `runs-settled` value
120
+ * was typed five times while the real fault was elsewhere. The interview is the
121
+ * expensive half and it had already succeeded — only the apply phase failed —
122
+ * so the answers are saved on that failure and named in the retry command.
123
+ *
124
+ * The binding is what stops this being a stale cache. A resume file carries the
125
+ * hash of the config it was answered against, and the version that wrote it:
126
+ * replaying an old plan over newer policy is precisely the silent fake here, and
127
+ * refusing loudly is cheaper than a config quietly rolled back.
128
+ */
129
+ export interface SetupResumeFile {
130
+ /** Config bytes hash at interview time; a change refuses the replay. */
131
+ configHash: string;
132
+ /** The package version that recorded it — a schema fence across upgrades. */
133
+ version: string;
134
+ /** The area/project the answers belong to, for the printed retry command. */
135
+ area?: string;
136
+ project?: string;
137
+ answers: SetupAnswerValues;
138
+ }
139
+
140
+ const resumeFileSchema = z.object({
141
+ configHash: z.string().min(1),
142
+ version: z.string().min(1),
143
+ area: z.string().optional(),
144
+ project: z.string().optional(),
145
+ answers: answerFileSchema,
146
+ });
147
+
148
+ /** The config generation a resume file is bound to: its exact bytes, or `absent`. */
149
+ export function setupConfigHash(read: () => string | undefined): string {
150
+ const raw = read();
151
+ if (raw === undefined) return "absent";
152
+ return new Bun.CryptoHasher("sha256").update(raw).digest("hex").slice(0, 32);
153
+ }
154
+
155
+ /**
156
+ * Persist the confirmed answers of a failed apply phase, 0600 because it holds
157
+ * whatever the interview held — no more, and in the same account's state
158
+ * directory the config itself lives in.
159
+ */
160
+ export function saveSetupResume(path: string, file: SetupResumeFile): void {
161
+ writeFileSync(path, `${JSON.stringify(file, null, 2)}\n`, { encoding: "utf8", mode: 0o600 });
162
+ }
163
+
164
+ export type SetupResumeRead =
165
+ | { ok: true; file: SetupResumeFile }
166
+ | { ok: false; problem: string };
167
+
168
+ /**
169
+ * Read a resume file and check its binding against the live config.
170
+ *
171
+ * A hash mismatch is refused rather than merged: the answers describe a
172
+ * decision made about a different config, and applying them would overwrite
173
+ * whatever changed it. So is a version mismatch — the answer keys are a schema,
174
+ * and an upgraded package may have renamed one.
175
+ */
176
+ export function readSetupResume(path: string, liveConfigHash: string, version: string): SetupResumeRead {
177
+ let raw: string;
178
+ try {
179
+ raw = readFileSync(path, "utf8");
180
+ } catch (err) {
181
+ return { ok: false, problem: `could not read resume file "${path}": ${err instanceof Error ? err.message : String(err)}` };
182
+ }
183
+ let decoded: unknown;
184
+ try {
185
+ decoded = JSON.parse(raw);
186
+ } catch (err) {
187
+ return { ok: false, problem: `resume file "${path}" is not valid JSON: ${err instanceof Error ? err.message : String(err)}` };
188
+ }
189
+ const parsed = resumeFileSchema.safeParse(decoded);
190
+ if (!parsed.success) {
191
+ return { ok: false, problem: `resume file "${path}" is not a setup resume record` };
192
+ }
193
+ if (parsed.data.version !== version) {
194
+ return {
195
+ ok: false,
196
+ problem:
197
+ `resume file "${path}" was written by omp-conductor ${parsed.data.version}, this is ${version} — ` +
198
+ "the answer keys are a schema, so re-run the interview rather than replaying answers across an upgrade",
199
+ };
200
+ }
201
+ if (parsed.data.configHash !== liveConfigHash) {
202
+ return {
203
+ ok: false,
204
+ problem:
205
+ `resume file "${path}" was answered against a different config (recorded ${parsed.data.configHash}, ` +
206
+ `live ${liveConfigHash}) — replaying it would overwrite whatever changed it. Re-run the interview`,
207
+ };
208
+ }
209
+ return { ok: true, file: parsed.data };
210
+ }
211
+
115
212
  /** Turns exhausted scripted stdin into an actionable non-interactive failure. */
116
213
  export function guardNonInteractiveUi(inner: TerminalUi): TerminalUi {
117
214
  const exhausted = (title: string, options: PromptOptions): never => {