bareloop 0.26.0 → 0.28.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.
package/src/text.js CHANGED
@@ -8,8 +8,12 @@
8
8
  // record, for the same reason again — a second writer that forgets the scrub is
9
9
  // a leak on the very trail the scrub exists to protect.
10
10
 
11
+ import { createRequire } from 'node:module';
11
12
  import { redactSecrets } from './validate.js';
12
13
 
14
+ const require = createRequire(import.meta.url);
15
+ const { HaltError } = require('bare-agent');
16
+
13
17
  // A fence counts as the artifact's WRAPPER only when it opens within this many
14
18
  // lines of the response — the chatty-preamble shape ("Here's the fix:\n```…").
15
19
  // A fence buried deeper inside an unfenced reply is the artifact's OWN content
@@ -149,6 +153,47 @@ export function capStop({ ceilingUsd, knownUsd, spendComplete }) {
149
153
  return null;
150
154
  }
151
155
 
156
+ /**
157
+ * THE ONE SPELLING of "was this throw a call casualty, or something else" —
158
+ * F179/F180's seam. `authorscout.js`'s `settled` narrows a rejected
159
+ * `loop.run()` to this one class: swallowing every throw would launder a
160
+ * budget `HaltError`, a programming bug, or any other named terminal into
161
+ * "the call failed" — the exact class of dishonesty this repo refuses (an
162
+ * unknown reported as a known).
163
+ *
164
+ * bare-agent 0.42.0's `OpenAIProvider.generate` used to throw a raw
165
+ * `SyntaxError` from an unguarded `JSON.parse(tc.function.arguments)` after
166
+ * a billed HTTP round (F179/BA-27) — this predicate used to admit that class
167
+ * too. bare-agent 0.43.0 fixed it upstream (`parseToolCalls`, shared by
168
+ * OpenAIProvider and Ollama): a malformed tool-call arguments string now
169
+ * returns a priced round with `toolCalls: []` plus its own `malformedToolCall`
170
+ * marker instead of throwing. Verified against 0.43.0's installed source that
171
+ * no provider bareloop constructs (`AnthropicProvider`, `OpenAIProvider`,
172
+ * `GeminiProvider` — `src/providers.js`) can still throw a JSON `SyntaxError`
173
+ * out of `generate()` after a billed round: Anthropic and Gemini read tool-call
174
+ * arguments as already-parsed objects off their own response shape (nothing to
175
+ * `JSON.parse`), and every provider's raw-HTTP-body parse is already
176
+ * try/catch-wrapped inside `_request`, converted to a plain `Error` before it
177
+ * can reach here. bareloop never streams a provider response. So the
178
+ * SyntaxError branch this predicate used to carry is dead code and was
179
+ * removed in the same change that bumped to bare-agent 0.43.0 — re-add it (or
180
+ * a narrower successor) only if a real provider is found to reintroduce the
181
+ * class.
182
+ *
183
+ * A `HaltError` is deliberately excluded — it is bare-agent's own governance
184
+ * exit (a budget/turn cap), never a transport casualty, and this predicate
185
+ * must never launder one into a mere "the call failed".
186
+ * @param {any} err
187
+ * @returns {string|null} a short reason when `err` is an admitted casualty class,
188
+ * `null` for everything else (including `HaltError`) — the caller re-throws on `null`.
189
+ */
190
+ export function callCasualty(err) {
191
+ if (err instanceof HaltError) return null;
192
+ const e = /** @type {any} */ (err);
193
+ if (e?.code === 'ETIMEDOUT' || e?.name === 'TimeoutError') return String(e?.message ?? e);
194
+ return null;
195
+ }
196
+
152
197
  // ── THE RAW MODEL OUTPUT, AS A PERSISTED RECORD ─────────────────────────────
153
198
  //
154
199
  // Run mslhn707 died on a survey that broke JSON at position 1339 and left NO
@@ -0,0 +1,73 @@
1
+ // THE COLD RESET, in ONE place.
2
+ //
3
+ // `run-u.mjs` has always owned this: a cold run starts at the frozen seed with the
4
+ // `.litectx` store removed, because a run that inherits the previous run's edits — or
5
+ // the previous run's isolate-verb memory (stash/remember persist in the store) — is
6
+ // measuring the wrong thing. The reuse rung's OFF arm and every cold contrast depend
7
+ // on it.
8
+ //
9
+ // It moved here the day a BATTERY driver needed to rehearse it (the read-shim Phase 2
10
+ // battery's `--dry-run` has to prove the patient really does go cold between rows).
11
+ // The alternative was a second spelling of `git reset --hard` in the battery, and two
12
+ // spellings of "cold" is exactly how one driver's rows come to start from a different
13
+ // tree than the other's — the blind-instrument class, at the patient.
14
+ //
15
+ // Nothing here decides anything and nothing here is arbiter territory: it is the
16
+ // mechanical preparation of a tree, and both callers print what it did.
17
+ import { execFileSync } from 'node:child_process';
18
+ import {
19
+ rmSync, existsSync, renameSync,
20
+ } from 'node:fs';
21
+ import { join } from 'node:path';
22
+
23
+ /**
24
+ * Reset a patient to its frozen seed and drop its `.litectx` store.
25
+ *
26
+ * @param {string} wd absolute path to the patient's tree (a git repo with >=1 commit)
27
+ * @param {string} seed the frozen seed commit
28
+ * @returns {{ head: string, storeRemoved: boolean }} `head` is the short sha the tree
29
+ * now stands at; `storeRemoved` says whether a store was actually there to remove
30
+ * (so a caller can print the truth rather than a claim).
31
+ */
32
+ export function coldReset(wd, seed) {
33
+ const git = (/** @type {string[]} */ a) => execFileSync('git', ['-C', wd, ...a], { encoding: 'utf8' }).trim();
34
+ git(['reset', '--hard', seed]);
35
+ git(['clean', '-fd']);
36
+ const store = join(wd, '.litectx');
37
+ const storeRemoved = existsSync(store);
38
+ rmSync(store, { recursive: true, force: true });
39
+ return { head: git(['rev-parse', '--short', 'HEAD']), storeRemoved };
40
+ }
41
+
42
+ /**
43
+ * F186 — the patient's own `.gitignore` denies `*.jsonl`, so `coldReset`'s
44
+ * `git clean -fd` (no `-x`) never removes a `gate-audit.jsonl` already
45
+ * sitting at the tree root — it can only be there from an EARLIER run
46
+ * against this same patient (an authoring scout, or a prior worker run)
47
+ * that never got archived out. Left in place, THIS run's own Gate (which
48
+ * writes to that same path) would append to it, and the end-of-run rename
49
+ * that claims the whole file as "this run's own audit" would silently
50
+ * carry every earlier run's rows too — a harness slicing a shared append-
51
+ * only log has to account for every writer inside its window.
52
+ *
53
+ * Call this AFTER `coldReset` and BEFORE this run's own Gate ever opens: at
54
+ * that point a `gate-audit.jsonl` sitting in the tree is provably not this
55
+ * run's, because coldReset just proved the tree is unable to have produced
56
+ * one since the seed reset. NOT for a resume — a halted run's own gate
57
+ * audit already in the tree it resumes into is that SAME run's prior leg,
58
+ * not a stranger's; callers on the resume path must not call this.
59
+ *
60
+ * @param {string} wd absolute path to the patient's tree, just cold-reset
61
+ * @param {string} spineDir absolute path to this run's spine directory
62
+ * @param {string} runid this run's own id (the same one every other spine
63
+ * file for this run is named after)
64
+ * @returns {string|null} the path the stale file was moved to, or `null`
65
+ * when there was nothing to move
66
+ */
67
+ export function moveStaleGateAudit(wd, spineDir, runid) {
68
+ const staleAudit = join(wd, 'gate-audit.jsonl');
69
+ if (!existsSync(staleAudit)) return null;
70
+ const preFile = join(spineDir, `pre-${runid}-gate-audit.jsonl`);
71
+ renameSync(staleAudit, preFile);
72
+ return preFile;
73
+ }
@@ -0,0 +1,451 @@
1
+ // The U runner's end-of-run readout, in the one place a test can reach it.
2
+ //
3
+ // `run-u.mjs` is a script: importing it runs it. So the one line of it whose
4
+ // arithmetic was WRONG lives here instead, and the runner calls it.
5
+ //
6
+ // F83, the small unfixed item: the banner printed money FOLDED (`job-end`'s total
7
+ // across every leg of a resume) and wall LEG-ONLY (`Date.now() - started` of this
8
+ // process), both against the SAME signed cap, and said which for neither. On
9
+ // u-msf70nei that read `wall 12.8min of 45min` for a leg that resumed onto 24.9
10
+ // already-burnt minutes. Nothing was mis-ENFORCED — `createClock` folds
11
+ // `priorElapsedMs` and the outside watchdog armed on the 20.1min remainder — the
12
+ // readout simply computed the wall a second way, which is the two-transforms class
13
+ // (F9) the clock's own `enforcedMs` comment exists to refuse.
14
+ //
15
+ // Both figures stay on the line. The folded total is the one the cap governs, so it
16
+ // leads; the leg is what this process actually bought, and dropping it would trade
17
+ // one blindness for the other.
18
+
19
+ /** minutes, at the precision the readout has ever claimed (~90%, hamr's ruling) */
20
+ const min = (/** @type {number} */ ms) => (ms / 60_000).toFixed(1);
21
+
22
+ /**
23
+ * A token count, k/M-abbreviated — 2 decimals at and above a million (the tail's own
24
+ * `.2M`-shaped resolution matters more once six figures are folded into a suffix), 1
25
+ * decimal in the thousands, and the bare integer under that (a two- or three-digit
26
+ * round is more readable plain than as `"0.2k"`).
27
+ * @param {number} n
28
+ * @returns {string}
29
+ */
30
+ export function fmtTokens(n) {
31
+ if (!Number.isFinite(n)) return '?';
32
+ if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(2)}M`;
33
+ if (n >= 1_000) return `${(n / 1_000).toFixed(1)}k`;
34
+ return String(Math.round(n));
35
+ }
36
+
37
+ /**
38
+ * THE TOKENS TAIL LINE — hamr's 2026-09-01 order: `job-end` carries dollars only, and
39
+ * the run tail never prints a token total anywhere, though tokens exist per round on
40
+ * `worker-round.usage` and `judge-round.usage`
41
+ * ({inputTokens, outputTokens, cacheReadTokens, cacheCreationTokens}).
42
+ *
43
+ * Summed off THIS LEG's own spine, over every round TYPE that spends money —
44
+ * worker-round AND judge-round (house rule: a spend-slicing instrument must enumerate
45
+ * every round type that spends, or it silently undercounts). `worker-result` is an
46
+ * ATTEMPT-LEVEL ECHO of round sums, not a round, and is deliberately never matched
47
+ * here — folding it in would double the real figure.
48
+ *
49
+ * A round whose `usage` is missing or not an object (today: every `judge-round` — the
50
+ * payload at `src/planrun.js`'s `onJudgeCost` does not carry one) is counted as
51
+ * UNKNOWN, never laundered into a silent 0 — the same rule `costUsd` already lives by
52
+ * (F6/F44): an unpriced round is reported as unpriced, not folded into the total as
53
+ * if it cost nothing.
54
+ *
55
+ * @param {{events: any[]}} o `events` this leg's own parsed spine, never a folded
56
+ * chain — a resumed leg's tokens are this leg's own bill, same as `rounds` above it.
57
+ * @returns {string}
58
+ */
59
+ export function tokensLine({ events }) {
60
+ const list = Array.isArray(events) ? events : [];
61
+ const num = (/** @type {unknown} */ v) => (typeof v === 'number' && Number.isFinite(v) ? v : 0);
62
+ let inputTokens = 0;
63
+ let outputTokens = 0;
64
+ let cacheReadTokens = 0;
65
+ let cacheCreationTokens = 0;
66
+ let unpriced = 0;
67
+ for (const e of list) {
68
+ if (!e || typeof e !== 'object') continue;
69
+ if (e.type !== 'worker-round' && e.type !== 'judge-round') continue;
70
+ const u = e.usage;
71
+ if (!u || typeof u !== 'object') { unpriced += 1; continue; }
72
+ inputTokens += num(u.inputTokens);
73
+ outputTokens += num(u.outputTokens);
74
+ cacheReadTokens += num(u.cacheReadTokens);
75
+ cacheCreationTokens += num(u.cacheCreationTokens);
76
+ }
77
+ const total = inputTokens + outputTokens + cacheReadTokens + cacheCreationTokens;
78
+ const note = unpriced > 0 ? ` (${unpriced} round${unpriced === 1 ? '' : 's'} unpriced/no usage)` : '';
79
+ return `${fmtTokens(total)} · in ${fmtTokens(inputTokens)} · out ${fmtTokens(outputTokens)} · `
80
+ + `cache-read ${fmtTokens(cacheReadTokens)} · cache-write ${fmtTokens(cacheCreationTokens)}${note}`;
81
+ }
82
+
83
+ /**
84
+ * The banner's wall line: what this run has consumed of its signed cap.
85
+ * @param {{legMs: number, priorWallMs?: number, wallLabel: string}} o
86
+ * `legMs`: wall this process bought. `priorWallMs`: wall its predecessors burnt,
87
+ * as `readResume` folded it (0/absent on a cold run). `wallLabel`: the signed cap
88
+ * as the runner spells it, including the honest UNBOUNDED prose.
89
+ * @returns {string}
90
+ */
91
+ export function wallLine({ legMs, priorWallMs = 0, wallLabel }) {
92
+ // the belt every sibling fold site carries (src/clock.js's `prior`, readResume's
93
+ // four): a non-finite or negative fold contributes 0. A NaN here would render
94
+ // `NaNmin` and a negative would report LESS wall than the leg demonstrably took.
95
+ const prior = typeof priorWallMs === 'number' && Number.isFinite(priorWallMs) && priorWallMs > 0 ? priorWallMs : 0;
96
+ // a cold run has no fold and gets exactly the line it always got
97
+ if (prior === 0) return `${min(legMs)}min of ${wallLabel}`;
98
+ return `${min(legMs + prior)}min of ${wallLabel} (this leg ${min(legMs)}min)`;
99
+ }
100
+
101
+ /**
102
+ * THE EVIDENCE PACKAGE (N4, ruling 2) — what a person is shown before they answer.
103
+ *
104
+ * *"Before/after changes plus every mechanical stage's result; never a bare
105
+ * 'approve?'"*. The LIBRARY assembles the facts and puts them on the `hitl-pause`
106
+ * spine record — the ask the close itself declared, every stage's own result, and
107
+ * WHICH files the run changed (`src/planrun.js`'s `emitHitlPause`). This renders
108
+ * them, and adds the one thing a library cannot state honestly from where it sits:
109
+ * the LINE-level diff, which needs a screen and a repository in front of it.
110
+ *
111
+ * Nothing here derives a verdict, and nothing here decides. It is a readout of a
112
+ * record that already exists — so an absence is written as prose (F6: an empty
113
+ * stage list is "the checkpoint carries none", never a blank screen that reads as
114
+ * "nothing happened") and a trim is announced (F28).
115
+ *
116
+ * @param {{pause: any, diff: {lines: string[], truncated?: number, untracked?: string[],
117
+ * unavailable?: string|null}|null}} o `pause` the spine record; `diff` what the
118
+ * runner read off the patient, or null when it did not look.
119
+ * @returns {string[]} lines, ready to print
120
+ */
121
+ export function evidencePackage({ pause, diff }) {
122
+ const p = pause ?? {};
123
+ /** @type {string[]} */
124
+ const out = [];
125
+ out.push('HUMAN REVIEW — this run reached the one stage a machine cannot render, and it is waiting on you.');
126
+ out.push(` ask ${typeof p.ask === 'string' && p.ask.trim() ? p.ask : '(the close declared no question — review the result as it stands)'}`);
127
+
128
+ const stages = Array.isArray(p.stages) ? p.stages : [];
129
+ if (!stages.length) {
130
+ out.push(' stages no stage results on the checkpoint — nothing here can say what the machine judged, so read the diff below and the run\'s own spine');
131
+ } else {
132
+ out.push(' stages what the machine judged before it stopped, in the order it judged it:');
133
+ const w = Math.max(...stages.map((s) => String(s?.name ?? '?').length));
134
+ for (const s of stages) {
135
+ const name = String(s?.name ?? '?').padEnd(w);
136
+ const verdict = String(s?.verdict ?? 'unknown').padEnd(12);
137
+ // the MEASUREMENT, where a stage made one: `baseline → value` is the
138
+ // before/after a number can state, and it is the half of "before/after" a
139
+ // diff cannot show
140
+ const measured = typeof s?.value === 'number'
141
+ ? (typeof s?.baseline === 'number' ? ` ${s.baseline} → ${s.value}` : ` ${s.value}`)
142
+ : '';
143
+ // and whatever the stage ANNOUNCED about its own reading (a trim, a scope)
144
+ const notes = typeof s?.notes === 'string' && s.notes.trim() ? ` (${s.notes.trim()})` : '';
145
+ const waiting = s?.verdict === 'human-pause' ? ' ← waiting on you' : '';
146
+ out.push(` ${name} ${verdict}${measured}${notes}${waiting}`.trimEnd());
147
+ }
148
+ }
149
+
150
+ const changed = p.changed ?? {};
151
+ const paths = Array.isArray(changed.paths) ? changed.paths : [];
152
+ if (typeof changed.unreadable === 'string' && changed.unreadable.trim()) {
153
+ // F6 — an unreadable set is UNKNOWN, and it must never render as a clean tree
154
+ out.push(` changed UNKNOWN — the run could not read its own changed set: ${changed.unreadable.trim()}`);
155
+ } else if (!paths.length) {
156
+ out.push(' changed no changed files recorded on the checkpoint');
157
+ } else {
158
+ const more = typeof changed.more === 'number' && changed.more > 0 ? `, and ${changed.more} more not listed` : '';
159
+ out.push(` changed ${paths.length} file(s) this run touched${more}:`);
160
+ for (const f of paths) out.push(` ${f}`);
161
+ }
162
+
163
+ if (diff) {
164
+ if (typeof diff.unavailable === 'string' && diff.unavailable) {
165
+ out.push(` diff line-level diff UNAVAILABLE — ${diff.unavailable}`);
166
+ } else {
167
+ const lines = Array.isArray(diff.lines) ? diff.lines : [];
168
+ const untracked = Array.isArray(diff.untracked) ? diff.untracked : [];
169
+ out.push(' diff the patient AS IT STANDS NOW, against the seed (it can move while a run is paused — that is why accept re-runs the mechanical stages):');
170
+ for (const l of lines) out.push(` ${l}`);
171
+ if (typeof diff.truncated === 'number' && diff.truncated > 0) {
172
+ out.push(` … ${diff.truncated} more diff line(s) not shown — read the patient for the rest`);
173
+ }
174
+ if (untracked.length) {
175
+ out.push(` NEW file(s), which have no diff at all: ${untracked.join(', ')}`);
176
+ }
177
+ if (!lines.length && !untracked.length) out.push(' (the tree matches the seed — nothing to show)');
178
+ }
179
+ }
180
+ return out;
181
+ }
182
+
183
+ /**
184
+ * THE REVIEW DOOR's package (softgreen module 8) — the same evidence, at the door
185
+ * at the END of a run rather than at a stage inside the close.
186
+ *
187
+ * It is a SEPARATE renderer from `evidencePackage` and deliberately so: that one
188
+ * opens with *"this run reached the one stage a machine cannot render, and it is
189
+ * waiting on you"*, which at a review door would be false twice over — the machine
190
+ * rendered every stage, and the run is over. What a person needs told here is the
191
+ * opposite fact: a verdict was minted, it STANDS, and what happens to the work is
192
+ * theirs. The stage/changed/diff body is shared, because it is the same evidence.
193
+ * @param {{door: any, diff?: any}} o
194
+ * @returns {string[]}
195
+ */
196
+ export function reviewDoorPackage({ door, diff }) {
197
+ const d = door ?? {};
198
+ const held = d.quarantined === true;
199
+ /** @type {string[]} */
200
+ const out = [];
201
+ out.push(`REVIEW DOOR — this run's close minted ${String(d.outcome ?? 'a verdict')} and it STANDS. Nothing you do here changes it.`);
202
+ if (held) {
203
+ out.push(' held this green was rendered by the judged floor, so it has earned NO reuse and NO learning credit until you accept it');
204
+ }
205
+ // the body: what the machine judged, what moved, and the lines themselves —
206
+ // rendered by the SAME code the pause screen uses, off the same record shape.
207
+ // The ASK line is dropped when there is no ask: a door asks nothing of its own
208
+ // (it offers three answers to a verdict already minted), and the pause renderer's
209
+ // stand-in sentence would be inventing a question nobody declared. Keyed on the
210
+ // gutter rather than on that sentence's wording, which is not this file's to pin.
211
+ const hasAsk = typeof d.ask === 'string' && d.ask.trim() !== '';
212
+ out.push(...evidencePackage({ pause: { ...d, ask: d.ask ?? null }, diff }).slice(1)
213
+ .filter((l) => hasAsk || !l.startsWith(' ask ')));
214
+ return out;
215
+ }
216
+
217
+ /**
218
+ * THE DOOR LIST ITSELF — the header, the order, and the gutter, spelled ONCE.
219
+ *
220
+ * Both screens that offer the three doors (the step-level pause and the review
221
+ * door at the end of a run) show the same list in the same order for the same
222
+ * reason; only the CONSEQUENCES differ, so only the prose is passed in. Spelling
223
+ * the structure twice is how the two screens come to disagree about which door
224
+ * leads — and the order is the whole design input (see `doorLines`).
225
+ * @param {{rerun: string, accept: string, pause: string}} prose what each door
226
+ * costs on THIS screen, without its trailing colon
227
+ * @param {{rerun: string, accept: string, pause: string}} cmd the exact command
228
+ * each door costs, so nobody has to reconstruct one
229
+ * @returns {string[]}
230
+ */
231
+ function threeDoors(prose, cmd) {
232
+ return [
233
+ 'THREE DOORS — no fourth, and no decision is taken for you: nothing happens without the flag.',
234
+ ` rerun ${prose.rerun}:\n ${cmd.rerun}`,
235
+ ` accept ${prose.accept}:\n ${cmd.accept}`,
236
+ ` pause ${prose.pause}:\n ${cmd.pause}`,
237
+ ];
238
+ }
239
+
240
+ /**
241
+ * The door's OWN `close-timing-red` refusal (F137, hamr's ruling 2026-09-07,
242
+ * `docs/product/CLOSE-INTEGRITY-BUILD.md` "Ruled 2026-09-07"): when the
243
+ * door's timing pass (`resolveCloseTimeoutMs`) times out, the accept must
244
+ * REFUSE outright rather than fall through to the library's 120s default —
245
+ * mirroring the wording/options list `scripts/run-u.mjs`'s in-run
246
+ * `close-timing-red` escalation already prints (~line 1173), never a second
247
+ * invented phrasing. Pure so the wording is unit-testable without spawning
248
+ * the script or waiting out the real 600s timing-preflight ceiling.
249
+ * @param {{names: string}} args comma-joined names of the stage(s) that timed out
250
+ * @returns {string[]}
251
+ */
252
+ export function doorTimingRedLines({ names }) {
253
+ return [
254
+ `CLOSE-TIMING-RED (door) — stage(s) never finished the timing preflight: ${names}. `
255
+ + 'A close stage did not finish within the timing preflight\'s own ceiling — the close cannot run on this '
256
+ + 'machine in a boundable time, so nothing was run, nothing was recorded, and nothing was spent.',
257
+ ' investigate why the stage hangs (infra/network/resource issue)',
258
+ ' sign an explicit closeTimeoutMs override once the real duration is known',
259
+ ' abandon the task',
260
+ ];
261
+ }
262
+
263
+ /**
264
+ * The three doors AT THE REVIEW DOOR. Same order and the same rule as
265
+ * `doorLines` (rerun leads, ~40% rubber-stamp), different consequences: this run
266
+ * is OVER, so a rerun is a fresh engagement rather than a continuation, and a
267
+ * pause keeps the door rather than a step checkpoint.
268
+ * @param {{rerun: string, accept: string, pause: string, ttlDays: number, held: boolean}} o
269
+ * @returns {string[]}
270
+ */
271
+ export function runDoorLines({ rerun, accept, pause, ttlDays, held }) {
272
+ return threeDoors({
273
+ rerun: '"I don\'t like it, go again" — your words become the requirement a FRESH engagement plans against (its own clock, what is left of the signed budget)',
274
+ accept: `${held ? 'releases this run\'s learning credit' : 'confirms it'} — and the close\'s MECHANICAL stages re-run first, because the tree can move after a run ends`,
275
+ pause: `not now. Nothing runs and nothing is spent; the door keeps for ${ttlDays} days and then expires on its own, which is all that "cancel" ever meant`,
276
+ }, { rerun, accept, pause });
277
+ }
278
+
279
+ /**
280
+ * THE THREE DOORS, rendered — and the 2026-08-12 §4 rule is the ORDER.
281
+ *
282
+ * ~40% of human reviewers approve without reading. That datum is a design input:
283
+ * the default must lean toward rerun and never toward accept, because a default
284
+ * that costs one extra cycle when wrong is cheap and a default that mints a green
285
+ * nobody read is the failure mode the whole class exists to prevent.
286
+ *
287
+ * "Default" here is which door the prompt LEADS with — never an action taken for
288
+ * the operator. No door happens without its flag, and this says so.
289
+ *
290
+ * The third door is PAUSE (2026-08-18), not cancel. hamr: *"what's the point of
291
+ * cancel anyways? pause can resume — that would be more honest"*. A person who does
292
+ * not want to carry on today is not making a permanent judgment, and the door should
293
+ * not ask them to: the checkpoint keeps, and an unanswered one expires on its own.
294
+ *
295
+ * @param {{rerun: string, accept: string, pause: string}} invocations the exact
296
+ * command each door costs, so nobody has to reconstruct one
297
+ * @returns {string[]}
298
+ */
299
+ export function doorLines({ rerun, accept, pause }) {
300
+ return threeDoors({
301
+ rerun: 'your words BECOME the gap the worker fixes from, and the run continues under what is left of the budget',
302
+ accept: 'green — minted on FRESH evidence (the mechanical stages re-run first, since the tree may have moved)',
303
+ pause: 'not now. Nothing is run and nothing is spent; the checkpoint keeps and this run resumes from the start of its last step whenever you come back',
304
+ }, { rerun, accept, pause });
305
+ }
306
+
307
+ /**
308
+ * WHEN THE DEAD LEG STOPPED — the input `readResume` folds into `priorWallMs`, and
309
+ * therefore what the resumed leg's wall remainder is computed from.
310
+ *
311
+ * The runner has two candidate witnesses and they answer different questions:
312
+ *
313
+ * - the WATCHDOG's kill record (`<spine>.watchdog.json`), which is written when the
314
+ * outside guard reaps a run. For a run that was killed it is later and better
315
+ * evidence than the last spine event: the process was alive, silently, right up to
316
+ * the signal, and billing only to its last emitted event would under-report the
317
+ * wall it really burnt.
318
+ * - the SPINE's own landed terminal (`job-end`). A run that wrote one did not die —
319
+ * it ENDED ITSELF and dated its own stop.
320
+ *
321
+ * So the preference order is not "the later record wins", it is "the run's own record
322
+ * wins when there is one". N4 §1.6 is where that distinction stopped being academic:
323
+ * a `hitl-pause` is a clean terminal a person may answer days later, and any record
324
+ * dated after it bills their deciding time to the run's wall. The POC measured the
325
+ * shape — a report 45 days after the pause made `RESUME_WALL_MS` exactly 0, dooming
326
+ * the resumed leg before it opened, on a run that had barely started. W-2 holds by
327
+ * construction only if this reads the pause.
328
+ *
329
+ * Returning `null` is not a failure: it hands `readResume` back its own documented
330
+ * default (the last event's timestamp), which for a clean terminal IS the terminal.
331
+ *
332
+ * @param {{watchdogAt?: unknown, events?: unknown}} o `watchdogAt` as the report
333
+ * spells it (an ISO string, or anything at all — the file is read off disk);
334
+ * `events` the dead spine, parsed.
335
+ * @returns {number|null} ms, or null to leave the default in place
336
+ */
337
+ export function deathAtOf({ watchdogAt, events } = {}) {
338
+ const list = Array.isArray(events) ? events : [];
339
+ // a landed terminal is the run dating its own stop — nothing outside it is better
340
+ // evidence about a run that was never killed
341
+ if (list.some((e) => e !== null && typeof e === 'object' && /** @type {any} */ (e).type === 'job-end')) return null;
342
+ const at = typeof watchdogAt === 'string' ? Date.parse(watchdogAt) : NaN;
343
+ // an unreadable stamp is UNKNOWN, never a NaN handed to a fold (F6): the caller's
344
+ // own default is the honest fallback
345
+ return Number.isFinite(at) ? at : null;
346
+ }
347
+
348
+ /**
349
+ * WHERE A RESUME PICKS UP — the preview's `at` line, which is what says how much of
350
+ * the signed dollars this leg can still spend.
351
+ *
352
+ * `readStepCheckpoint` (src/reuse.js) knows two phases and a null: `steps` (re-enter
353
+ * the plan at the first unfinished step), `close` (every step landed; the close and
354
+ * its fix loop are what is left), and no checkpoint at all (the halt landed before a
355
+ * plan was accepted). The runner rendered those three directly, and a PAUSE fits none
356
+ * of them cleanly:
357
+ *
358
+ * - a pause happens AFTER the plan's steps, at the close's human stage. On the
359
+ * fixture a person actually meets, the checkpoint reads `phase:'steps'` with every
360
+ * step green (the pause site emits no `outer-close` of its own), so the step
361
+ * arithmetic ran off the end and printed `at step 2 of 1 "(unknown)"` — a step
362
+ * count larger than the plan and an id nobody drafted. hamr found it on the first
363
+ * resume preview he read;
364
+ * - and the same walk-off is reachable one terminal over, with no person involved:
365
+ * a kill between the last `step-end` and the `outer-close` leaves exactly that
366
+ * shape, and read `step 3 of 2`.
367
+ *
368
+ * So the count is never allowed to exceed the plan, and the PHASE is said in words.
369
+ * Rendering only; it decides nothing and bounds nothing.
370
+ *
371
+ * @param {{seed: {phase?: string, plan?: any, completedSteps?: any[]}|null|undefined,
372
+ * paused: boolean}} o `seed` is `readResume`'s `restart.seed`; `paused` is the
373
+ * recorded terminal being `hitl-pause` — read off the spine, never off a flag.
374
+ * @returns {string[]} lines, ready to print
375
+ */
376
+ export function resumeAtLines({ seed, paused }) {
377
+ const at = (/** @type {string} */ s) => ` at ${s}`;
378
+ const reloaded = ' the plan is reloaded from that run\'s own spine: no re-scout, no re-draft';
379
+ if (!seed) {
380
+ // the pause has its OWN cold shape (src/planrun.js's close precheck: every
381
+ // mechanical stage already passed on the untouched tree, so the run asked its
382
+ // person before drafting anything). "the beginning" is where a resume of it
383
+ // starts and is not the phase it stopped in — both facts, in that order.
384
+ if (paused) {
385
+ return [at('the human review — no plan was ever accepted: this run reached its human stage at the close PRECHECK, '
386
+ + 'before it drafted anything'),
387
+ ' an accept re-runs the mechanical stages for no tokens; a rerun starts the plan from the beginning'];
388
+ }
389
+ return [at('the beginning — it halted before a plan was accepted, so nothing paid is re-payable')];
390
+ }
391
+ const total = Array.isArray(seed.plan?.steps) ? seed.plan.steps.length : 0;
392
+ const done = Array.isArray(seed.completedSteps) ? seed.completedSteps.length : 0;
393
+ if (paused) {
394
+ return [at(`the human review — all ${total} step(s) finished and are SKIPPED; the close reached its human stage, `
395
+ + 'and it re-runs for no tokens (what your answer costs after that is the door you pick)'), reloaded];
396
+ }
397
+ if (seed.phase === 'close') {
398
+ return [at(`the close and its fix loop — all ${total} step(s) are done and are SKIPPED; the close re-runs for no tokens`), reloaded];
399
+ }
400
+ // the plan is EXHAUSTED but no close was recorded: the halt landed between the last
401
+ // step and the close's first reading. There is no next step to name, and inventing
402
+ // one is what produced `step 3 of 2 "(unknown)"`.
403
+ if (done >= total) {
404
+ return [at(`the close — all ${total} step(s) finished and are SKIPPED, and the run stopped before the close rendered; `
405
+ + 'it runs next, for no tokens'), reloaded];
406
+ }
407
+ return [at(`step ${done + 1} of ${total} ${JSON.stringify(seed.plan.steps[done]?.id ?? '(unknown)')} — ${done} already finished and SKIPPED, not re-paid`), reloaded];
408
+ }
409
+
410
+ /**
411
+ * F97 — the DOOMED RESUME read, the one the operator should make before signing.
412
+ *
413
+ * `u-msn0uccv` spent $0.82 re-entering the exact plan whose action was the diagnosed
414
+ * defect, carrying `priorReplans: 2` and `priorReplanGrantUsed: true`. A resume does
415
+ * not re-draft — `scout-skipped {reason:"resumed"}` and the plan comes back
416
+ * byte-for-byte — so with the replan ledger empty there was no mechanism left that
417
+ * could replace it. Two attempts, 26 rounds, 0 writes, `step-red` with money and
418
+ * minutes still on the clock. Nothing in the library was at fault; every part of that
419
+ * leg reported honestly. The DECISION to fire was the defect, and the read that would
420
+ * have refused it costs $0: both facts are already parsed by the preview gate.
421
+ *
422
+ * WARNING ONLY. This never blocks and never changes behaviour — a resume of a
423
+ * mechanical gap (a named wall, a count) converts on re-entry all the time, and the
424
+ * operator is the one who can tell which kind of gap it is. Blocking here would be
425
+ * this file inventing a gate; the arbiter's gates are signed, not derived from a
426
+ * readout module.
427
+ *
428
+ * @param {{seed?: {phase?: string}|null, replans?: number, replanGrantUsed?: boolean}|null} [restart]
429
+ * `readResume`'s `restart` fold: WHERE it picks up and WHAT LEDGER it inherited.
430
+ * @returns {boolean} true only for the shape F97 measured.
431
+ */
432
+ export function doomedResume(restart) {
433
+ // WHERE. `phase: 'steps'` is the re-entry that reloads the accepted plan and runs
434
+ // it again. `'close'` is not this shape and must not warn: every step FINISHED, so
435
+ // nothing failed plan is being re-entered, and the outer close fix loop writes
436
+ // without spending a replan. `null` (no `plan-accepted` in the window) is the cold
437
+ // path — that resume re-drafts, which is the very escape this warning is about.
438
+ if (restart?.seed?.phase !== 'steps') return false;
439
+ // WHAT IT HAS LEFT. The ceiling is a latch of ONE (PRD v1.12 — unlimited replanning
440
+ // launders thrash as adaptation) plus the arbiter's single variance-granted extra
441
+ // (F85-C). Both spent is zero capacity; a count above zero with the grant STILL
442
+ // UNEARNED is conditional capacity, and warning there would fire the banner on the
443
+ // commonest resume there is, which trains the eye to skip the line that matters.
444
+ //
445
+ // The count is belted the way every other fold site belts one, and in the fail-safe
446
+ // direction FOR A WARNING: an unreadable ledger is not evidence of exhaustion, so it
447
+ // stays silent rather than claiming a fact it cannot read (F6 — unknown is never
448
+ // rounded into a claim).
449
+ const spent = typeof restart.replans === 'number' && Number.isFinite(restart.replans) && restart.replans > 0;
450
+ return spent && restart.replanGrantUsed === true;
451
+ }