bareloop 0.27.0 → 0.29.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.
@@ -0,0 +1,1345 @@
1
+ // RUN-AUTHOR — the first LIVE end-to-end of the close-authoring pipeline
2
+ // (M1–M4b) — lifted out of `scripts/run-author.mjs` into a library module
3
+ // (PANEL-BUILD.md P0 task 4/4, the same shape tasks 1-3 already used).
4
+ // ONE argv-parsing `main(argv, deps)`, exported; `scripts/run-author.mjs` is
5
+ // now a thin adapter over it, the same ~10-line shape `bin/bareloop.mjs`
6
+ // already is over `src/cli.js`.
7
+ //
8
+ // This is a LIFT, not a rewrite: the orchestration below is `scripts/
9
+ // run-author.mjs`'s own body, unchanged in its logic — every `console.log`/
10
+ // `console.error` became `out`/`err` (with ONE deliberate exception, marked
11
+ // where it sits: the crash catch's raw `console.error(authorErr)`, kept as a
12
+ // direct `console.error` call rather than routed through `err()`, so Node's
13
+ // own multi-line Error/stack formatting — the exact bytes an unhandled
14
+ // rejection used to print — survives byte-for-byte), every `process.exit(n)`
15
+ // became a thrown `ExitSignal(n)` (a library function may never call
16
+ // `process.exit` itself), `process.exitCode = n` became a local `exitCode = n`
17
+ // returned at the bottom, `process.env` became an injectable `env`, and
18
+ // `process.argv` became the `argv` parameter. The library calls this script
19
+ // already made (`authorCloseForJob`, `assembleSpec`, `prepareSigning`,
20
+ // `resolveJobJudge`, `buildRunnerProviders`, …) were already library and are
21
+ // untouched.
22
+ //
23
+ // THE ENDING-OWNER CONSOLIDATION (PANEL-BUILD.md P0, also in scope for this
24
+ // task): the original script had 6 real `process.exit()` calls and 7
25
+ // `emit('author-end', …)` sites, written by hand at different points across
26
+ // 1182 lines. There is still exactly one PLACE each ending is minted — every
27
+ // `author-end` still fires from the same call site it always did (this is a
28
+ // behaviour-preserving lift) — but there is now exactly one MECHANISM by
29
+ // which any of them can stop the function early: `throw new ExitSignal(n)`,
30
+ // caught by the outer `try`/`catch` at the bottom of `main`, which is the
31
+ // single ending owner in the sense that matters — nothing below this file's
32
+ // own entry point calls `process.exit` any more, anywhere, on any path
33
+ // (ordinary stop, refusal, crash, or a real OS signal's kill path, which
34
+ // re-raises the signal itself rather than inventing an exit code, exactly as
35
+ // the original script already did).
36
+ //
37
+ // The original file's own comments about WHY each `process.exit()`/
38
+ // `process.exitCode` line was placed where it is are kept, verbatim, below —
39
+ // updated only where the mechanism itself changed (`process.exit()` →
40
+ // `ExitSignal`) and the reasoning needs to say so honestly.
41
+ //
42
+ // The picked class's interview answers in; a REAL scout over a REAL repository; a REAL model
43
+ // filling the typed declaration form; the operator's own half of the spec folded
44
+ // in; the job validator; and D9's three mechanical gates. What comes out is a
45
+ // resolved spec, its hash, and the evidence to sign against.
46
+ //
47
+ // IT STOPS AT prepareSigning. It never signs and it never runs the job: the
48
+ // approvals array and the human's word are the arbiter relocating to the user,
49
+ // not disappearing (src/authorjob.js §3). There is no --approve here because
50
+ // there is nothing here to approve — signing and running are run-u's territory.
51
+ //
52
+ // A REFUSAL IS A RESULT. If the interview refuses, or the close cannot be
53
+ // authored, or a stage cannot run against the patient, or nothing is red at the
54
+ // seed — that comes back verbatim, is written to the out dir, and exits 1. It is
55
+ // counted demand against bareloop's own catalogue, never an error to swallow.
56
+ //
57
+ // node scripts/run-author.mjs \
58
+ // --source /path/to/prepared/tree --answers answers.json --draft specdraft.json \
59
+ // --verdict green --out /path/to/outdir [--timeout 300000] \
60
+ // [--budget 2.50]
61
+ //
62
+ // --source REPLACES --patient (PRD item 33 M3, ruling 2). It must be a
63
+ // PREPARED tree — the `tree/` a source door already froze
64
+ // (`scripts/prep-source.mjs`, or `run-interview.mjs`'s own
65
+ // Source/Destination questions): `readSourceManifest(dirname
66
+ // (source))` must find a manifest of kind 'repo' beside it.
67
+ // An unprepared path dies loud, naming the exact command to
68
+ // prepare one first; a prepared NON-repo source (a plain
69
+ // folder/file/URL) stops with the same honest "no checks yet"
70
+ // message `run-interview.mjs` gives (M3 ruling 7 → M4).
71
+ //
72
+ // --budget THE AUTHORING CEILING, in dollars, and it has NO DEFAULT. The
73
+ // pipeline pays for a survey (up to three attempts plus a
74
+ // reserved round) and a declaration loop (an author call, its
75
+ // revises, and each one's malformed-emission retries); with a
76
+ // ceiling set, the run stops BETWEEN calls the moment the spend
77
+ // reaches it. Left off, the run is UNBOUNDED and says so on
78
+ // stdout before it spends anything — a defaulted cap would be a
79
+ // silent second ceiling (the `maxWallMs` precedent), and an
80
+ // unbounded run must be a VISIBLE operator choice, never a state
81
+ // arrived at by omission.
82
+ //
83
+ // --verdict THE RADIO (PRD v1.57 §1): green | soft-green. It is the
84
+ // USER's answer, so it is asked rather than defaulted — a
85
+ // defaulted class would be this script answering a question the
86
+ // person was asked.
87
+ // answers.json {"1": "...", ...} the picked class's own frozen questions,
88
+ // keyed by the LIBRARY's own numbers (`requiredAnswersFor`) —
89
+ // green: five, soft-green: those five plus two more (the signed
90
+ // rubric card and the frozen calibration set). Never a
91
+ // count spelled here: two slots have already been deleted (D13's
92
+ // genre confirm, then the repo question hamr dropped once the
93
+ // mandatory --patient made it redundant), and a hardcoded number
94
+ // would have gone stale twice.
95
+ // specdraft.json the OPERATOR half of a job-v1 spec — budgets, fence, cadence,
96
+ // goal, tools, escalation. NO close, NO verdictType: the close is
97
+ // what this pipeline authors, and the class comes from --verdict.
98
+ // There is no --provider flag either: the scout's and the
99
+ // drafter's provider is the draft's OWN `provider` field (PRD
100
+ // item 34 L17) — `run-interview.mjs` asks for it and writes it
101
+ // in; a draft missing one, or naming one the provider factory
102
+ // does not know, dies here loud, listing the known table.
103
+ import {
104
+ readFileSync, writeFileSync, appendFileSync, mkdirSync, existsSync, renameSync, statSync,
105
+ } from 'node:fs';
106
+ import { dirname, join, resolve } from 'node:path';
107
+ import { createInterface } from 'node:readline';
108
+ import {
109
+ authorCloseForJob, assembleSpec, prepareSigning, refusalEvents,
110
+ VERDICT_CLASSES, LIVE_CLASSES, MENU_CLASSES, questionsFor, AUTHORED_SPEC_FIELDS,
111
+ REFUSAL_LIB, REFUSAL_CATEGORY,
112
+ } from './authorjob.js';
113
+ import {
114
+ makeLoopGenerate, CONFIRM_MENU, CONFIRM_SYSTEM,
115
+ } from './authorflow.js';
116
+ import { defaultJudgeLoop, resolveJobJudge } from './judged.js';
117
+ import { validateJob, jobSpecHash, resolveWorkerModel } from './job.js';
118
+ import { scanSecrets, redactSecrets } from './validate.js';
119
+ import { detectLanguage } from './detectlang.js';
120
+ import { closeJudges, GATE_AUDIT_FILE } from './kinds.js';
121
+ import { resolveProvider, buildRunnerProviders, apiKeyProblem } from './providers.js';
122
+ import { readSourceManifest, missingDependencies } from './source.js';
123
+ import { tallyCalls } from './text.js';
124
+ import {
125
+ declarationLines, rubricLines, calibrationLines, parseCeiling, ceilingLine, crashRecord, phaseLine,
126
+ openQuestionLines, answeredQuestionLines, fellBackLines,
127
+ } from './authorreadout.js';
128
+
129
+ /** Thrown to unwind `main` to an exit code without ever calling
130
+ * `process.exit()` (constraint: a library function returns a code / throws,
131
+ * it never calls `process.exit` itself). Caught at the bottom of `main` —
132
+ * never expected to escape it. */
133
+ class ExitSignal extends Error {
134
+ /** @param {number} code */
135
+ constructor(code) {
136
+ super(`exit ${code}`);
137
+ this.code = code;
138
+ }
139
+ }
140
+
141
+ /**
142
+ * @typedef {object} Deps
143
+ * @property {Record<string,string|undefined>} [env]
144
+ * @property {NodeJS.ReadStream} [stdin]
145
+ * @property {NodeJS.WritableStream} [stdout]
146
+ * @property {NodeJS.WritableStream} [stderr]
147
+ * @property {string} [invokedAs] the command name this run was actually
148
+ * reached through, for the usage message ONLY — `scripts/run-author.mjs`
149
+ * supplies none (defaults to its own real invocation), `src/cli.js` passes
150
+ * `'bareloop author'` so a person who typed that is told to re-run THAT,
151
+ * never a script path they never invoked and may not have on disk.
152
+ */
153
+
154
+ /**
155
+ * Parses this script's own argv shape and runs the authoring pipeline
156
+ * (scout, declaration loop, D9's gates) against the given (or real)
157
+ * stdin/stdout. `scripts/run-author.mjs` is the thin adapter over this, the
158
+ * same shape `bin/bareloop.mjs` already is over `src/cli.js`'s own
159
+ * `main(argv, deps)`.
160
+ * @param {string[]} argv
161
+ * @param {Deps} [deps]
162
+ * @returns {Promise<number>} an exit code — never calls process.exit
163
+ */
164
+ export async function main(argv, deps = {}) {
165
+ const env = deps.env ?? process.env;
166
+ const stdin = deps.stdin ?? process.stdin;
167
+ const stdout = deps.stdout ?? process.stdout;
168
+ const stderr = deps.stderr ?? process.stderr;
169
+ const out = (/** @type {string} */ s = '') => { stdout.write(`${s}\n`); };
170
+ const err = (/** @type {string} */ s) => { stderr.write(`${s}\n`); };
171
+ const invokedAs = deps.invokedAs ?? 'node scripts/run-author.mjs';
172
+ let exitCode = 0;
173
+ try {
174
+ /** the close precheck / seed read spawns real toolchains; the slowest stage is a
175
+ * suite. Headroom, not a budget — and it is passed EXPLICITLY rather than left
176
+ * to a default, because a defaulted cap is a silent second ceiling. */
177
+ const DEFAULT_TIMEOUT_MS = 300_000;
178
+
179
+ const arg = (/** @type {string} */ n) => { const i = argv.indexOf(`--${n}`); return i === -1 ? null : (argv[i + 1] ?? ''); };
180
+ /** `@returns {never}` is load-bearing, not decorative (the same rule src/userrun.js's
181
+ * own `die` carries): it is what lets tsc narrow every `if (x.stop !== null) die(...)`
182
+ * / `if (!x.present) die(...)` site below to the safe branch of a union afterward.
183
+ * @param {string} m @returns {never} */
184
+ const die = (m) => { err(m); throw new ExitSignal(2); };
185
+
186
+ // `--patient` IS GONE (PRD item 33 M3, ruling 2): `--source` replaces it, and
187
+ // must be a PREPARED tree — see the usage comment above. Stopped loud rather
188
+ // than silently ignored, the same rule `--lang`'s removal already applies.
189
+ if (arg('patient') !== null) {
190
+ die('--patient is no longer a flag — use --source <tree>, the prepared copy a source door produces '
191
+ + '(scripts/prep-source.mjs, or run-interview.mjs\'s own Source/Destination questions). PRD item 33 M3, ruling 2.');
192
+ }
193
+ const sourceArg = arg('source');
194
+ const answersArg = arg('answers');
195
+ const draftArg = arg('draft');
196
+ const outArg = arg('out');
197
+ const verdictArg = arg('verdict');
198
+ // `--lang` IS GONE (PRD item 33 M3, ruling 3): language is a FACT of the
199
+ // repository, read off its own manifest, never a flag a person sets. Stopped
200
+ // loud rather than silently ignored, same rule the interview script applies.
201
+ if (arg('lang') !== null) {
202
+ die('--lang is no longer a flag — language is auto-detected from --source\'s own manifest '
203
+ + '(package.json/pyproject.toml/setup.py), never asked or set (PRD item 33 M3, ruling 3). Drop --lang and rerun.');
204
+ }
205
+ const timeoutArg = arg('timeout');
206
+ const TIMEOUT_MS = timeoutArg === null ? DEFAULT_TIMEOUT_MS : Number(timeoutArg);
207
+ /** NO DEFAULT, deliberately: `null` is "nobody set one" and means UNBOUNDED. It
208
+ * is never filled in here — the whole point of the flag is that the number is
209
+ * the operator's, and a script that supplies one on their behalf has set a
210
+ * ceiling nobody chose. Parsed through `author-readout.mjs` so the rule has a
211
+ * home a test can reach. */
212
+ const { ceilingUsd: CEILING_USD, error: budgetError } = parseCeiling(arg('budget'));
213
+
214
+ if (!sourceArg || !answersArg || !draftArg || !outArg || verdictArg === null) {
215
+ die(`usage: ${invokedAs} --source <tree> --answers <answers.json> --draft <specdraft.json> `
216
+ + `--verdict <${MENU_CLASSES.join('|')}> --out <outdir> [--timeout <ms>] [--budget <usd>]`);
217
+ }
218
+ // A malformed ceiling dies at the door rather than silently reading as absent.
219
+ if (budgetError) die(budgetError);
220
+ // the menu is handed over enumerated; an unknown value is a typo, and the
221
+ // interview refuses it as one (a LOCKED class is a different answer — it reaches
222
+ // the pipeline and comes back as counted demand). The CHECK stays against the
223
+ // full `VERDICT_CLASSES` (so an off-menu pick reaches the pipeline's counted
224
+ // refusal rather than dying here as a typo); the PRINTED text names only the
225
+ // menu (item 34 L19: nothing customer-facing names an off-menu class).
226
+ const VERDICT = /** @type {string} */ (verdictArg);
227
+ if (!VERDICT_CLASSES.includes(VERDICT)) die(`--verdict ${VERDICT} is not a verdict class — one of ${MENU_CLASSES.join(' | ')}`);
228
+ if (!Number.isFinite(TIMEOUT_MS) || TIMEOUT_MS <= 0) die(`--timeout ${timeoutArg} is not a positive number of milliseconds`);
229
+
230
+ const SOURCE = resolve(/** @type {string} */ (sourceArg));
231
+ if (!existsSync(SOURCE)) die(`--source ${SOURCE} does not exist — the scout reads a prepared source tree off the machine, never out of prose`);
232
+ const OUT = resolve(/** @type {string} */ (outArg));
233
+
234
+ // ── THE SPINE, BOOTSTRAPPED FIRST — before anything that can refuse, before
235
+ // any provider is resolved or built, and before the API key is even read.
236
+ // Moved here (out of its old position, further down, right before the first
237
+ // paid span) so that EVERY refusal this script can produce — starting with
238
+ // the manifest and language checks immediately below — has somewhere to
239
+ // record itself. A `die()` before this point is still bare stderr+exit(2):
240
+ // those are pure operator/config errors (missing flags, a bad number) with
241
+ // no spine to write into yet and nothing they represent counts as demand.
242
+ mkdirSync(OUT, { recursive: true });
243
+ const runid = Date.now().toString(36);
244
+ const spineFile = join(OUT, `author-${runid}.jsonl`);
245
+ /** the spine: one JSONL line per event, appended. This runner owns it, exactly
246
+ * as the shell owns `runJob`'s — `authorjob.js` emits nothing itself.
247
+ * @param {string} type @param {any} [data] */
248
+ const emit = (type, data = {}) => {
249
+ appendFileSync(spineFile, `${JSON.stringify({ type, ts: new Date().toISOString(), ...data })}\n`);
250
+ };
251
+ /** @param {string} name @param {any} body */
252
+ const writeOut = (name, body) => {
253
+ const f = join(OUT, name);
254
+ writeFileSync(f, `${JSON.stringify(body, null, 2)}\n`);
255
+ return f;
256
+ };
257
+
258
+ // F186 — the scout's gate audit (`src/authorscout.js`'s `defaultSurveyor`,
259
+ // default `auditPath = join(workdir, GATE_AUDIT_FILE)`) writes the
260
+ // arbiter's own book DIRECTLY INTO THE PATIENT TREE, at its root — the same
261
+ // place `scripts/run-u.mjs` later looks for a fresh worker run's own audit.
262
+ // The patient's `.gitignore` denies `*.jsonl`, so a cold `git clean -fd`
263
+ // never removes it: it survives across every authoring run against the
264
+ // same tree, accreting rows from every run_id that has ever touched it,
265
+ // until something moves it out of the way. `run-u.mjs`'s own half of this
266
+ // fix (item 3b) moves a STALE audit aside at launch; this half archives
267
+ // THIS run's own audit OUT of the tree the moment authoring is done, so a
268
+ // later run — authoring or worker — never finds it there at all.
269
+ //
270
+ // Idempotent by construction (checks `existsSync` itself) so it is safe to
271
+ // call from more than one exit path without double-moving or throwing on
272
+ // the second call. Best-effort: a rename failure is reported, never thrown
273
+ // — the same F70 reasoning the crash handler already follows (a report
274
+ // that can itself crash defeats the report).
275
+ let gateAuditArchived = false;
276
+ const archiveGateAudit = () => {
277
+ if (gateAuditArchived) return;
278
+ gateAuditArchived = true;
279
+ const treeAudit = join(SOURCE, GATE_AUDIT_FILE);
280
+ if (!existsSync(treeAudit)) return;
281
+ const archived = join(OUT, `author-${runid}-${GATE_AUDIT_FILE}`);
282
+ try {
283
+ renameSync(treeAudit, archived);
284
+ out(`gate audit ${archived} (moved out of the patient tree — F186)`);
285
+ } catch (e) {
286
+ err(`gate audit could not be archived out of the patient tree: ${/** @type {NodeJS.ErrnoException} */ (e)?.message ?? e}`);
287
+ }
288
+ };
289
+
290
+ // ── --SOURCE MUST BE A PREPARED TREE (PRD item 33 M3, ruling 2) ─────────────
291
+ // `--source` replaced `--patient`; it is no longer just "a repository on the
292
+ // machine" — it must be the FROZEN COPY a source door already produced
293
+ // (`scripts/prep-source.mjs`, or `run-interview.mjs`'s own Source/Destination
294
+ // questions), so `dirname(SOURCE)` (the door's own `into`) carries a
295
+ // `source.json` manifest right beside `tree/`, which IS `SOURCE` here. A
296
+ // config error (never prepared, or a manifest that cannot be read) dies
297
+ // loud, before the API key is even read — same standing as every other
298
+ // argv/config `die()` in this file.
299
+ const manifestRead = await readSourceManifest(dirname(SOURCE));
300
+ if (manifestRead.stop !== null) {
301
+ die(`--source ${SOURCE}: ${manifestRead.code} — ${manifestRead.stop}`);
302
+ }
303
+ if (!manifestRead.present) {
304
+ die(`--source ${SOURCE} was never prepared through the source front door — no source.json beside it. Prepare it first:\n`
305
+ + ' node scripts/prep-source.mjs --source <path-or-url> --into <dir>\n'
306
+ + 'then rerun this script with --source <dir>/tree.');
307
+ }
308
+ // A manifest that EXISTS but names a NON-repo kind (a plain folder/file/URL)
309
+ // is a different thing entirely: the source was prepared correctly, and
310
+ // there is simply no check catalogue for it yet (M3 ruling 7 → M4). F191
311
+ // (D5 amended 2026-09-21): that honest stop fires immediately, right after
312
+ // `author-start`, at $0 — no scout, no confirm turn, no model call. D5's
313
+ // original shape (a plain-folder job gets a paid confirm turn first) is
314
+ // unreachable by construction now that the close catalogue is code-genre
315
+ // only, so no confirm turn over a plain folder could ever confirm a plan
316
+ // this build can close (see the stop itself, below, for the full story).
317
+ // `IS_REPO_SOURCE` gates the language-detection block right below (a plain
318
+ // folder has no genre to detect) and the branch further down that decides
319
+ // which of the two paths this run actually takes.
320
+ const IS_REPO_SOURCE = manifestRead.manifest.kind === 'repo';
321
+
322
+ // ── LANGUAGE, DETECTED — never asked (PRD item 33 M3, ruling 3) ──────────────
323
+ // $0, no provider, run BEFORE the API key is even read — this script must
324
+ // reach here on `--source` alone, with no `--lang` needed. Run against the
325
+ // PREPARED TREE, never the original patient the door froze it from. Same
326
+ // four outcomes `run-interview.mjs` reads (that script normally catches the
327
+ // two that stop something first, but this script is also runnable standalone
328
+ // against a hand-written answers.json/specdraft.json, so it repeats the same
329
+ // two early stops rather than relying on the interview having run first):
330
+ // - `ambiguous` — two languages' manifests at the same level;
331
+ // stopped here with the honest list (the
332
+ // confirm-turn UI to ask a person is later M3).
333
+ // An OPERATOR FIX (point --source at the right
334
+ // subfolder), never demand — no spine record.
335
+ // - `language-unsupported` — a known manifest with no genre data yet;
336
+ // stopped here, before the API key check, so a
337
+ // job that can never be authored costs nothing.
338
+ // COUNTED DEMAND: emitted through the same
339
+ // `refusalEvents()` channel every other refusal
340
+ // in this script uses, so it folds into the
341
+ // ledger's admission count (src/ledger.js
342
+ // `classifyIncidents`) instead of vanishing.
343
+ // - `resolved` / `no-code-job` — carried into `lang` below exactly where
344
+ // the old `--lang` value flowed; `no-code-job` reads as `'none-detected'`,
345
+ // which the existing GENRE_LANGUAGES check further down refuses on its
346
+ // own (M3 ruling 7: a plain-folder job is not an error here, it is simply
347
+ // not a code-genre job this pipeline can close yet).
348
+ // Only meaningful for a REPO source (PRD item 33 M3 piece 4, step S6): a
349
+ // plain folder has no genre to detect and no code-shaped manifest to walk —
350
+ // `LANG`/`langResult` stay at their "nothing to see" values and the confirm
351
+ // turn further down runs with `isRepo: false` (D5), which is what already
352
+ // skips the repo-only "worse than before" ask and the language pick alike.
353
+ let langResult = /** @type {ReturnType<typeof detectLanguage>|null} */ (null);
354
+ let LANG = 'none-detected';
355
+ if (IS_REPO_SOURCE) {
356
+ langResult = detectLanguage(SOURCE);
357
+ // AMBIGUOUS NO LONGER DIES (PRD item 33 M3 piece 4, step S4): the confirm
358
+ // turn's own $0 half asks the person which language this job is about,
359
+ // BEFORE the scout (D7) — `langResult` travels into `authorCloseForJob`
360
+ // below exactly as it is here, and its `ambiguous` shape is what triggers
361
+ // that ask. `LANG` below is a placeholder for this outcome only: whatever
362
+ // the person picks interactively is what actually lands in `closeDecl.lang`.
363
+ if (langResult.kind === 'language-unsupported') {
364
+ const r = langResult.refusal;
365
+ out(`REFUSED (${r.kind}) verb=${r.verb} path=${r.path}`);
366
+ out(r.detail);
367
+ for (const o of r.options) out(` · ${o}`);
368
+ for (const e of refusalEvents(r)) emit(e.type, e);
369
+ out(`\nRecorded as admission demand in the spine: ${spineFile}`);
370
+ // A stop this early has no `authored`/`signing` result to fall through to —
371
+ // the rest of this file's flow assumes a resolved language, a provider and
372
+ // a spec draft, none of which exist yet. PANEL-BUILD.md P0's ending-owner
373
+ // consolidation is what turned this from a bare `process.exit(1)` into a
374
+ // thrown `ExitSignal(1)`, caught by the OUTER catch at the bottom of
375
+ // `main` (below `main`'s own inner try/catch/finally, never inside it) —
376
+ // a library function returns a code / throws, it never calls
377
+ // `process.exit` itself.
378
+ throw new ExitSignal(1);
379
+ }
380
+ LANG = langResult.kind === 'resolved' ? langResult.lang
381
+ : langResult.kind === 'ambiguous' ? langResult.candidates[0]
382
+ : 'none-detected';
383
+ if (langResult.kind === 'ambiguous') {
384
+ out(`Source has more than one supported language's manifest at the same (nearest) level: `
385
+ + `${langResult.candidates.join(', ')} (in ${langResult.dir}).`);
386
+ out('This will be asked, interactively, in the confirm turn below — before any paid call.');
387
+ }
388
+
389
+ // ── item 33 close-out: the install gap, refused at $0 (hamr's ruling
390
+ // 2026-09-14, option A) ── `prepareSource` copies only git-tracked files, so
391
+ // a JS/TS repo's copy never carries `node_modules`, and every close stage
392
+ // needing a tool (`tsc`, a test runner) would instrument-stop. bareloop
393
+ // never runs an install itself — this refuses BEFORE the scout, before the
394
+ // API key is even read, naming the exact command the person runs themselves
395
+ // in the copy. Routed through the same `refusalEvents()` channel every other
396
+ // $0 refusal in this script uses (the `language-unsupported` block above),
397
+ // never an ad-hoc print, so it folds into the ledger's admission count the
398
+ // same way.
399
+ const depsGap = missingDependencies(SOURCE, manifestRead.manifest.sourceSubdir ?? '');
400
+ if (depsGap) {
401
+ const detail = `${SOURCE} has a package.json listing dependencies but the copy has no node_modules (${depsGap.reason}) — every `
402
+ + 'close stage needing a tool would instrument-stop. bareloop never runs an install itself; run this in the copy, then rerun '
403
+ + `run-author.mjs against the same --source:\n cd ${SOURCE} && ${depsGap.command}`;
404
+ /** @type {import('./authorjob.js').Refusal} */
405
+ const refusal = {
406
+ kind: 'request-red', verb: 'source-deps-missing', path: 'source', detail,
407
+ options: [`run \`${depsGap.command}\` inside ${SOURCE}, then rerun run-author.mjs with the same --source`],
408
+ red: {
409
+ code: 'request-red', path: 'source', detail, verb: 'source-deps-missing', lib: REFUSAL_LIB, category: REFUSAL_CATEGORY,
410
+ },
411
+ };
412
+ out(`REFUSED (${refusal.kind}) verb=${refusal.verb} path=${refusal.path}`);
413
+ out(refusal.detail);
414
+ for (const o of refusal.options) out(` · ${o}`);
415
+ for (const e of refusalEvents(refusal)) emit(e.type, e);
416
+ out(`\nRecorded as admission demand in the spine: ${spineFile}`);
417
+ throw new ExitSignal(1);
418
+ }
419
+ }
420
+
421
+ /** @param {string} label @param {string} file */
422
+ const readJson = (label, file) => {
423
+ const p = resolve(file);
424
+ if (!existsSync(p)) die(`--${label} ${p} does not exist`);
425
+ try { return JSON.parse(readFileSync(p, 'utf8')); } catch (e) { return die(`--${label} ${p} is not readable JSON: ${e.message}`); }
426
+ };
427
+ const answers = readJson('answers', /** @type {string} */ (answersArg));
428
+ const draft = readJson('draft', /** @type {string} */ (draftArg));
429
+ // The draft is the OPERATOR half, and `assembleSpec` refuses one that already
430
+ // carries the authored half rather than merging over it. Asked HERE — before the
431
+ // scout, before any paid call — so a draft that cannot compose costs nothing to
432
+ // find out about; without this the same refusal arrives after the model has been
433
+ // paid, which is a true answer at the wrong price.
434
+ const carried = AUTHORED_SPEC_FIELDS.filter((f) => draft?.[f] !== undefined);
435
+ if (carried.length) {
436
+ die(`--draft ${draftArg} carries ${carried.join(', ')} — the draft is the operator's half only. `
437
+ + 'The close is what this pipeline authors and the class comes from --verdict; a draft that names either would be '
438
+ + 'silently overwritten, and a signed spec that does not contain what its author typed is the failure nobody sees.');
439
+ }
440
+
441
+ // THE AUTHORING PROVIDER (PRD item 34 L17) — resolved from the DRAFT's OWN
442
+ // `provider` field, the same rule `scripts/run-u.mjs` applies to the worker
443
+ // (`resolveProvider(spec.provider)`, no CLI flag, no default): authoring used
444
+ // to hardcode `anthropic-api` for both the scout and the drafter, which is
445
+ // exactly the scattering `src/providers.js`'s factory exists to stop. The
446
+ // draft interview (`run-interview.mjs`) now asks for a provider the same way
447
+ // it asks for everything else the operator owns; this script just resolves
448
+ // whatever it wrote down. Missing or unrecognized dies here, loud, the same
449
+ // message `resolveProvider` throws (it names the known table).
450
+ //
451
+ // STILL UNPROVEN LIVE past Anthropic: the drafting floor (PRD v1.36:
452
+ // sonnet-tier minimum) has only ever run against `anthropic-api` in practice.
453
+ // A DeepSeek draft is legal here today, but proving it is M3's DeepSeek proof
454
+ // run, not this script.
455
+ const providerEntry = (() => {
456
+ try { return resolveProvider(draft?.provider); } catch (e) { return die(/** @type {Error} */ (e).message); }
457
+ })();
458
+ const PROVIDER_NAME = /** @type {string} */ (draft.provider);
459
+ // The same tier run-u drafts and works on. The declaration is authored by a
460
+ // model, so the floor is the drafter floor (PRD v1.36: sonnet MINIMUM).
461
+ const MODEL = /** @type {NonNullable<typeof providerEntry>} */ (providerEntry).tiers.sonnet;
462
+ // spec.baseUrl (PRD item 28, ruling (d)) — the same forwarding rule run-u
463
+ // applies: forwarded only when the draft names one, every provider
464
+ // constructor defaults it on its own when absent.
465
+ const baseUrl = typeof draft?.baseUrl === 'string' ? draft.baseUrl : undefined;
466
+
467
+ // THE JUDGE THIS RUN'S OWN WORKER RESOLVES TO (PRD item 32.1) — resolved ONCE,
468
+ // here, from the DRAFT, and reused for BOTH the compose-time stamp (the
469
+ // declaration-authoring call's own `judgeModel` argument, below) and the
470
+ // calibration gate (`prepareSigning`'s `judgeModel` argument, further down).
471
+ // `resolveJobJudge` is the one
472
+ // spelling of "the named provider's own `tiers.sonnet` defaults an absent
473
+ // model, then a signed `judge` override wins" that `scripts/run-u.mjs`
474
+ // applies at run time (`resolveWorkerModel` + `resolveJudge`) — spelling that
475
+ // composition twice by hand, once here at the wrong identity (the AUTHORING
476
+ // provider/model) and once at the gate, was the bug this fixes: a close
477
+ // calibrated against judge X got stamped with model Y, and only the run-time
478
+ // read (`scripts/run-u.mjs:1144`) used the job's real worker, so the mismatch
479
+ // surfaced as a recalibration refusal on the close's first real run.
480
+ // `assembleSpec` carries `provider`/`model`/`judge` from the draft into the
481
+ // spec untouched, so resolving again from the assembled spec below (where the
482
+ // identity is guaranteed valid, because `validateJob` has already passed by
483
+ // then) returns the byte-identical pair — one rule, read twice, not two rules.
484
+ //
485
+ // This first read runs BEFORE `validateJob` has seen the draft, so a draft
486
+ // naming an unresolvable provider or a malformed `judge` override THROWS here
487
+ // — caught, because that draft is going to red cleanly at `validateJob` a few
488
+ // lines below regardless, and the placeholder this falls back to is discarded
489
+ // unsigned the moment it does.
490
+ const resolveDraftJudge = (/** @type {any} */ d) => {
491
+ try {
492
+ return resolveJobJudge(d, PROVIDER_NAME, resolveWorkerModel);
493
+ } catch {
494
+ return { provider: PROVIDER_NAME, model: MODEL };
495
+ }
496
+ };
497
+ const draftJudge = resolveDraftJudge(draft);
498
+
499
+ // Secrets load from the environment; they never enter argv (a command line is
500
+ // world-readable on /proc) and they are never printed.
501
+ const AUTHOR_ENV_KEY = /** @type {NonNullable<typeof providerEntry>} */ (providerEntry).envKey;
502
+ const apiKey = env[AUTHOR_ENV_KEY];
503
+ if (!apiKey) { err(`${AUTHOR_ENV_KEY} not set (secrets load from the environment — never the tree, never argv)`); throw new ExitSignal(2); }
504
+ // F181 — a key that carries a line break/control char/stray whitespace (a
505
+ // two-line secret-store entry, e.g.) reads as "set" by the presence check
506
+ // above and then crashes Node's own header-encode inside the paid span. This
507
+ // refuses at the SAME door, before any provider is constructed, and never
508
+ // echoes the value or the reason's source.
509
+ const AUTHOR_KEY_PROBLEM = apiKeyProblem(apiKey);
510
+ if (AUTHOR_KEY_PROBLEM) { err(`${AUTHOR_ENV_KEY} ${AUTHOR_KEY_PROBLEM} — refusing rather than crashing mid-call (never trimmed or repaired; fix the value at its source)`); throw new ExitSignal(2); }
511
+ /** The judge's key follows the RESOLVED judge provider's own env var, with
512
+ * `JUDGE_API_KEY` as the role-named override in front (PRD item 32.3) — the same
513
+ * contract `scripts/run-u.mjs` keeps, so one story covers both surfaces. When the
514
+ * judge IS the authoring provider this is the key already read above. */
515
+ const judgeKeyFor = (/** @type {string} */ providerName) => (
516
+ env.JUDGE_API_KEY ?? env[resolveProvider(providerName).envKey]
517
+ );
518
+
519
+ /** F6 — an unpriced call makes the TOTAL unknown, and unknown is reported as
520
+ * UNKNOWN. `?? 0` launders unknown into $0, and a bare floor that reads as exact
521
+ * is F6 in an honest coat, so the floor is marked `≥`.
522
+ * @param {any} cost a `makeCostBook().report()` */
523
+ const costLine = (cost) => {
524
+ // `cost === null` is not an unknown: `authorCloseForJob` returns a cost book on
525
+ // every path that reaches a model and `null` on every path that refuses before
526
+ // one (the interview, the language check, the seed read). So this states the
527
+ // FACT — no call was made — rather than reporting a number in either direction.
528
+ if (!cost) return 'not metered — this path refused before any model call';
529
+ if (cost.costUsd === null) {
530
+ return `UNKNOWN — ≥$${cost.knownUsd.toFixed(6)} known across ${cost.calls?.length ?? 0} call(s), `
531
+ + `${cost.nullCostCalls} unpriced call(s), ${cost.unpricedRounds} unpriced round(s)`;
532
+ }
533
+ return `$${cost.costUsd.toFixed(6)} across ${cost.calls?.length ?? 0} call(s) (spend complete)`;
534
+ };
535
+
536
+ out(`== close-authoring, run ${runid} == ${PROVIDER_NAME}/${MODEL}${baseUrl === undefined ? '' : ` (endpoint ${redactSecrets(baseUrl)})`}`);
537
+ out(` source ${SOURCE}`);
538
+ out(` verdict ${VERDICT} (the USER's pick — this run authors a close that promises to stay at or below it)`);
539
+ out(` lang ${LANG}`);
540
+ out(` draft ${resolve(/** @type {string} */ (draftArg))} (job "${draft?.job ?? '?'}")`);
541
+ out(` out ${OUT}`);
542
+ out(` timeout ${TIMEOUT_MS}ms per close stage`);
543
+ // THE UNBOUNDED RUN IS ANNOUNCED. Printed before the provider is even built, so
544
+ // it is on stdout ahead of the first paid byte rather than discovered in the
545
+ // total afterwards.
546
+ out(` ${ceilingLine(CEILING_USD)}`);
547
+ out(' stops at prepareSigning — this script NEVER signs and NEVER runs the job\n');
548
+
549
+ // F190 (docs/logs/FINDINGS.md) — this used to be a hand-rolled `makeProvider`
550
+ // call, a duplicate of the SAME construction `buildRunnerProviders` already
551
+ // owns for `src/cli.js`. Routed through the one owner now: the judge half of
552
+ // this call is a THROWAWAY (`judgeProviderName`/`judgeModel`/`judgeBaseUrl` all
553
+ // equal the author's own identity, so `buildRunnerProviders`' own reuse check
554
+ // returns the SAME instance as `provider` rather than constructing a second
555
+ // one) — the real judge identity is not resolved until after `validateJob`,
556
+ // further down, where a second call takes only its `.judgeProvider`.
557
+ const { provider } = buildRunnerProviders({
558
+ providerName: PROVIDER_NAME, apiKey, model: MODEL, tierModels: providerEntry.tiers, baseUrl,
559
+ judgeApiKey: apiKey, judgeModel: MODEL, judgeProviderName: PROVIDER_NAME, judgeBaseUrl: baseUrl,
560
+ });
561
+ emit('author-start', { runid, source: SOURCE, lang: LANG, verdictType: VERDICT, provider: PROVIDER_NAME, model: MODEL, baseUrl: baseUrl ?? null, job: draft?.job ?? null, timeoutMs: TIMEOUT_MS, ceilingUsd: CEILING_USD });
562
+
563
+ // ── EVERYTHING FROM HERE IS INSIDE ONE CATCH (F191) ──────────────────────────
564
+ // This USED TO start ~300 lines further down, right before the repo-shaped
565
+ // scout/authoring call — reasoned as "the argv and config die() paths run
566
+ // before the spine file exists, and a crash record with no spine to land in
567
+ // is a record nobody can read". That reason expired the moment the spine
568
+ // STARTED existing, one line above, at `author-start` — not ~300 lines later.
569
+ // Live run `mu4hc7sp` (F191) proved the gap live: a crash inside the OLD
570
+ // plain-folder confirm-turn branch (which used to sit between `author-start`
571
+ // and the old net's start) left a two-record spine with no ending at all —
572
+ // the exact "died and said nothing" failure this net exists to prevent.
573
+ // Moving the net's start here closes that gap: EVERYTHING from `author-start`
574
+ // on — the plain-folder stop below included — is now covered, whether or not
575
+ // it spends a cent (the crash message below says which).
576
+ //
577
+ // `rl` and `metered` are declared here, OUTSIDE the try, rather than where
578
+ // they are constructed/used below — the `catch`/`finally` blocks that read
579
+ // them (the crash message's own spend check, and the reader-close, F71) are
580
+ // SIBLINGS of the try, not nested inside it, so a binding made only inside
581
+ // the try would not exist there.
582
+ /** @type {ReturnType<typeof createInterface>|undefined} */
583
+ let rl;
584
+ /** EVERY METERED CALL, in the order they landed, in the ONE shape `costLine`
585
+ * already reads. A second hand-spelled running total is exactly the pair this
586
+ * file has already paid for once (the cap-halt/pricing-red type), so the totals
587
+ * are DERIVED from this list through `tallyCalls` — the same reader the library's
588
+ * own cost book uses — rather than accumulated a second time here.
589
+ * @type {{label: string, costUsd: number|null, unpricedRounds: number}[]} */
590
+ const metered = [];
591
+ // hoisted for the SAME reason `rl`/`metered` are: the ordinary-finish cleanup
592
+ // in the `finally` block below is a SIBLING of the try that populates it, not
593
+ // nested inside it, so a binding made only inside the try would not exist there.
594
+ /** @type {[string, () => void][]} */
595
+ let sigHandlers = [];
596
+ try {
597
+ // ── WHAT IS HAPPENING, AND WHAT IT HAS COST, WHILE IT IS STILL HAPPENING ─────
598
+ //
599
+ // Everything below reports; nothing below governs. The ceiling is enforced where
600
+ // it always was — `capStop`, between metered calls, inside the library — and no
601
+ // decision anywhere reads these.
602
+
603
+ /** the run's spend AS OF NOW, shaped exactly like a `makeCostBook().report()` so
604
+ * `costLine` renders it with no second spelling. F6 rides intact: an unpriced
605
+ * call makes `costUsd` null and the known half is reported as a `≥` floor. */
606
+ const costSoFar = () => ({ ...tallyCalls(metered), calls: metered.map((c) => ({ ...c })) });
607
+
608
+ /** the phase the run is inside, for the killed report below. A plain string
609
+ * rather than a stack: the question a killed run has to answer is "where did my
610
+ * money go", and the phase plus the cost line answers it. */
611
+ let phase = 'starting';
612
+ /** @param {string} name @param {any} [data] */
613
+ const onPhase = (name, data = {}) => {
614
+ phase = name;
615
+ // BEST-EFFORT, and deliberately: this is progress reporting on a PAID run, and
616
+ // a full disk or a closed pipe must never take down work that is being paid
617
+ // for. The run's real records (`authored.json`, the crash catch, `author-end`)
618
+ // are all downstream of this and unaffected.
619
+ try {
620
+ out(phaseLine(name, data));
621
+ emit('author-phase', { phase: name, ...data });
622
+ } catch { /* a reporter that kills the run it reports on is worse than silence (F70) */ }
623
+ };
624
+ /** @param {{label: string, costUsd: number|null, unpricedRounds: number}} call */
625
+ const onCall = (call) => {
626
+ metered.push({ ...call });
627
+ try {
628
+ const t = tallyCalls(metered);
629
+ out(`· ${call.label} — ${costLine(costSoFar())}`);
630
+ // `costUsd` rides as `null` when the call was unpriced — `?? 0` launders
631
+ // unknown into $0 (F6), and this record is what a killed run is read from.
632
+ emit('author-cost', {
633
+ label: call.label, costUsd: call.costUsd, unpricedRounds: call.unpricedRounds,
634
+ knownUsdSoFar: t.knownUsd, spendCompleteSoFar: t.spendComplete, calls: metered.length,
635
+ ceilingUsd: CEILING_USD,
636
+ });
637
+ } catch { /* see onPhase */ }
638
+ };
639
+
640
+ // ── A KILL LEAVES A BODY, exactly as a crash does ────────────────────────────
641
+ //
642
+ // SIGINT (the operator's own ^C on a run that looks hung), SIGTERM and SIGHUP
643
+ // (a closed terminal, a harness stopping the group) used to end this process
644
+ // with the spine holding ONE line — `author-start` — which is byte-for-byte what
645
+ // a run still in flight looks like, and with 100% of the spend record dying with
646
+ // the process. The paid calls had happened; nothing on disk said so.
647
+ //
648
+ // SIGKILL is NOT covered and cannot be: it is uncatchable by design, and there
649
+ // is no handler to write for it.
650
+ // Handler references are kept (not anonymous inline) SOLELY so a normal
651
+ // (non-killed) finish can remove them in the `finally` block below —
652
+ // `main` is now a library function a long-lived process (the panel) may
653
+ // call more than once, and a listener left on `process` after an ordinary
654
+ // return would accumulate across calls. A real signal still removes ALL of
655
+ // them itself first (`process.removeAllListeners(sig)`, unchanged below)
656
+ // before re-raising, so this is belt only for the path that never signals.
657
+ // (declared above, outside this try, for the same sibling-scope reason `rl`/
658
+ // `metered` are — reassigned here, never redeclared.)
659
+ sigHandlers = [];
660
+ for (const sig of ['SIGINT', 'SIGTERM', 'SIGHUP']) {
661
+ const handler = () => {
662
+ try {
663
+ emit('author-killed', { signal: sig, phase, ...costSoFar() });
664
+ emit('author-end', { outcome: 'killed', signal: sig });
665
+ } catch { /* a killed-handler that crashes destroys its own report (F70) */ }
666
+ err(`\nKILLED by ${sig} during ${phase} — spine: author-killed + author-end`);
667
+ err(` spent ${costLine(costSoFar())}`);
668
+ err(` spine ${spineFile}`);
669
+ // RE-RAISED rather than exited: the honest exit code for a signal death is
670
+ // 128+signo, and setting an exit code here — any of them — would report a
671
+ // killed run under a name this runner's vocabulary already spends on
672
+ // something else. The listener is removed first so the default disposition
673
+ // takes it (and so the handler cannot re-enter itself).
674
+ // `appendFileSync` inside `emit` is synchronous, so the record is already on
675
+ // disk before this line runs.
676
+ process.removeAllListeners(sig);
677
+ process.kill(process.pid, sig);
678
+ };
679
+ sigHandlers.push([sig, handler]);
680
+ process.on(sig, handler);
681
+ }
682
+
683
+ // ── THE ONE INTERACTIVE SEAM (PRD item 33 M3 piece 4, step S4) ───────────────
684
+ // The confirm turn (`runConfirmTurn`, wired below through `authorCloseForJob`)
685
+ // is the ONLY interactive part of this script — the survey, the declaration
686
+ // loop and D9's gates are all unattended. `terminal: false` for the same
687
+ // reason `run-interview.mjs` uses it (that script's own `nextLine`,
688
+ // scripts/run-interview.mjs:142-160): the TTY's own canonical mode already
689
+ // gives backspace and echo, and a piped stdin (a scripted session, a test)
690
+ // behaves identically either way — this is copied from that idiom rather
691
+ // than re-invented.
692
+ rl = createInterface({ input: stdin, terminal: false });
693
+ const rlLines = rl[Symbol.asyncIterator]();
694
+ /** @returns {Promise<string|null>} the next line, or null at end of input —
695
+ * the same "input ended" signal `run-interview.mjs`'s own `nextLine` uses. */
696
+ const nextLine = async () => {
697
+ const { value, done } = await rlLines.next();
698
+ if (done) { out(); return null; }
699
+ // a TTY echoes what a person types; a pipe does not, and a scripted
700
+ // session is being logged by definition — same rule, same reason.
701
+ if (!stdin.isTTY) out(redactSecrets(String(value)));
702
+ return String(value);
703
+ };
704
+ /** one free-text answer, possibly several lines; a blank line ends it.
705
+ * `allowBlank` lets the FIRST line be blank — `worseThanBefore`'s "nothing
706
+ * beyond Guardrails" is a legal, non-required answer, unlike every other
707
+ * free-text step below.
708
+ * @param {boolean} allowBlank @returns {Promise<string|null>} */
709
+ const readFreeText = async (allowBlank) => {
710
+ /** @type {string[]} */
711
+ const got = [];
712
+ for (;;) {
713
+ stdout.write(got.length ? ' … ' : ' > ');
714
+ const l = await nextLine();
715
+ if (l === null) return got.length ? redactSecrets(got.join('\n').trim()) : null;
716
+ if (String(l).trim() === '') { if (got.length || allowBlank) break; continue; }
717
+ got.push(String(l));
718
+ }
719
+ // SCRUBBED AT CAPTURE (same rule as run-interview.mjs's own readAnswer): no
720
+ // raw keystroke reaches anywhere — a prompt, a spine record, a file on
721
+ // disk — by a route that does not go through the one redactor first.
722
+ return redactSecrets(got.join('\n').trim());
723
+ };
724
+ /** `runConfirmTurn`'s own `ask` seam — one async function from "what's being
725
+ * asked" to the person's answer, or `null` meaning input ended (never a
726
+ * process exit here: this is a library call, and the library decides what a
727
+ * `null` means at each step — `confirm-abandoned` for every kind except the
728
+ * menu's `start-over`, which is a distinct explicit pick).
729
+ * @param {{kind: string, [k: string]: any}} step @returns {Promise<string|null>} */
730
+ const ask = async (step) => {
731
+ out('');
732
+ if (step.kind === 'worseThanBefore') {
733
+ out(step.field.prompt);
734
+ out(' (press Enter on a blank line for "nothing beyond Guardrails")');
735
+ return readFreeText(true);
736
+ }
737
+ if (step.kind === 'language') {
738
+ out(step.field.prompt);
739
+ for (const c of step.candidates ?? []) out(` · ${c}`);
740
+ return readFreeText(false);
741
+ }
742
+ if (step.kind === 'menu') {
743
+ const p = step.plan ?? {};
744
+ out('THE PLAN — read before you pick:');
745
+ out(` goal ${JSON.stringify(p.goal ?? '')}`);
746
+ out(' checks it will compose:');
747
+ for (const c of p.checks ?? []) out(` · ${c}`);
748
+ out(' protections (always-on guards, never named in the goal):');
749
+ for (const g of p.protections ?? []) out(` · ${g}`);
750
+ if ((p.notChecked ?? []).length) {
751
+ out(' you asked for these, but nothing checks them:');
752
+ for (const n of p.notChecked ?? []) out(` · ${n}`);
753
+ }
754
+ if ((p.questions ?? []).length) {
755
+ out(' open questions:');
756
+ for (const q of p.questions ?? []) out(` ? ${q}`);
757
+ }
758
+ const keys = /** @type {(keyof typeof CONFIRM_MENU)[]} */ (Object.keys(CONFIRM_MENU));
759
+ keys.forEach((k, i) => out(` ${i + 1}. ${CONFIRM_MENU[k]}`));
760
+ for (;;) {
761
+ stdout.write(` pick [1-${keys.length} or the word]: `);
762
+ const l = await nextLine();
763
+ if (l === null) return null;
764
+ const raw = String(l).trim().toLowerCase();
765
+ const byIndex = keys[Number(raw) - 1];
766
+ if (byIndex) return byIndex;
767
+ if (/** @type {string[]} */ (keys).includes(raw)) return raw;
768
+ out(` not a choice — type a number 1-${keys.length}, or one of: ${keys.join(', ')}`);
769
+ }
770
+ }
771
+ if (step.kind === 'answer') {
772
+ // F175's open half (2026-09-16): the plan raised a question of its own —
773
+ // "Confirm"/"Type the goal yourself" cannot proceed until it is
774
+ // answered, right here, with no extra model call. A blank line re-asks
775
+ // the same question ("Start over" or "Fix" are the ways out).
776
+ out(`Question ${step.index} of ${step.total} the plan raised — it must be answered before the plan can `
777
+ + 'be signed (pick "Start over" or "Fix" instead if you cannot answer it):');
778
+ out(` ? ${step.question}`);
779
+ return readFreeText(false);
780
+ }
781
+ if (step.kind === 'goal') {
782
+ out('Type the goal sentence yourself — it REPLACES the drafted one; the checks and protections stand.');
783
+ return readFreeText(false);
784
+ }
785
+ if (step.kind === 'fix') {
786
+ out('What should change? Describe the fix — it feeds the next round (up to 2 rounds total).');
787
+ return readFreeText(false);
788
+ }
789
+ return null;
790
+ };
791
+ /** the confirm turn's OWN model boundary (PRD item 33 M3 ruling 5 addendum) —
792
+ * bound to `CONFIRM_SYSTEM`, never the authoring `generate` above (that one
793
+ * is bound to `AUTHOR_SYSTEM`). Same provider instance, a different system
794
+ * prompt: reusing `generate` would run the wrong system prompt silently. */
795
+ const confirmGenerate = makeLoopGenerate(provider, { system: CONFIRM_SYSTEM });
796
+
797
+ // ── PLAIN-FOLDER SOURCE: A NAMED $0 STOP, NO MODEL CALL (F191; PRD item 33 M3
798
+ // piece 4, step S6, D5 amended 2026-09-21) ──────────────────────────────────
799
+ // This USED TO run the whole confirm turn first (over a $0 manual listing,
800
+ // via `runConfirmTurn`), THEN give this stop once the person confirmed a plan
801
+ // — D5's original shape. Live run `mu4hc7sp` (docs/logs/FINDINGS.md F191)
802
+ // crashed inside that confirm turn instead: `confirmProtections`
803
+ // (`src/authorflow.js`) calls `classGuards` (`src/authoring.js`), which is
804
+ // keyed by CODE LANGUAGE and THROWS on a plain folder's `lang:
805
+ // 'none-detected'` ("no TYPES genre data for language ...") — a plain folder
806
+ // has no language, so the very computation the confirm turn needs to show
807
+ // real, code-derived protections (F174's fix) cannot run for one. The crash
808
+ // left a two-record spine (`author-start`, `author-phase confirm`) with no
809
+ // ending at all.
810
+ //
811
+ // The fix is not a guard around that crash — it is recognizing D5's premise
812
+ // (a plain folder gets a paid confirm turn before the honest "no checks yet"
813
+ // stop) is now unreachable by construction: the close catalogue is code-genre
814
+ // only, so no confirm turn over a plain folder could ever confirm a plan this
815
+ // build can close. The stop fires HERE, immediately, at $0, no scout, no
816
+ // confirm turn, no model call — a plain folder's spine is exactly
817
+ // `author-start` → `author-end{outcome:'not-authored', stop:'non-code-source'}`.
818
+ if (!IS_REPO_SOURCE) {
819
+ const message = "This is a plain folder, not a code project. bareloop can't check this kind of "
820
+ + 'job yet. Nothing was spent. Your source was not changed.';
821
+ out(`\n${message}`);
822
+ const red = {
823
+ code: 'request-red', path: 'source', verb: 'non-code-source', lib: 'bareloop',
824
+ detail: `--source ${SOURCE} freezes a "${manifestRead.manifest.kind}" job — the close catalogue is code-genre only today.`,
825
+ };
826
+ emit('job-red', red);
827
+ writeOut('authored.json', { ok: false, confirmed: null, stop: 'non-code-source', cost: null, reds: [red] });
828
+ emit('author-end', { outcome: 'not-authored', stop: 'non-code-source' });
829
+ exitCode = 1;
830
+ } else {
831
+
832
+ // ── THE REPO-SHAPED CONTINUATION OF THE SAME TRY THAT OPENED RIGHT AFTER
833
+ // `author-start`, above — see that comment for why the net starts there and
834
+ // not here. What follows is: a real scout, a real model call, and a real
835
+ // toolchain per close stage.
836
+ // ── 1. answers → scout → the model fills the form → a close DECLARATION ──────
837
+ // `provider` drives the scout; `generate` is the declaration model boundary (one
838
+ // bare-agent Loop per call, the tool wired to end the call it is used in).
839
+ const authored = await authorCloseForJob({
840
+ // the judge that will certify this close's calibration set, if it composes a
841
+ // judged stage (PRD item 32.1). A DRAFT CAN carry a signed `judge` override
842
+ // already (`judge` is an OPERATOR field, not one of `AUTHORED_SPEC_FIELDS`) —
843
+ // `draftJudge`, resolved once above from the draft's own `provider`/`model`/
844
+ // `judge`, is what the spec will resolve to at run time (`scripts/run-u.mjs`),
845
+ // not this authoring run's own drafting identity.
846
+ judgeModel: draftJudge.model,
847
+ answers,
848
+ verdictType: VERDICT,
849
+ repoPath: SOURCE,
850
+ lang: LANG,
851
+ // the picked class's own frozen set — a LOCKED class never reaches here, it
852
+ // refuses at admission before its questions are ever asked, and asking for a
853
+ // locked set THROWS. Keyed off `LIVE_CLASSES` rather than the literal `green`
854
+ // so admitting a class (softgreen module 3 did exactly that) does not leave
855
+ // this line silently handing its questions back as `null`.
856
+ questions: LIVE_CLASSES.includes(VERDICT) ? questionsFor(VERDICT) : null,
857
+ // Q2 IS GONE (PRD item 33 M3 piece 3) — the operator's own `writeScope`
858
+ // (Destination's proven fence, `run-interview.mjs`) is what tells the
859
+ // composer what may change and what is read-only now; a draft with no
860
+ // fence is not this script's business to invent one for, so an absent or
861
+ // malformed field travels as `null` and `authorPrompt` simply states nothing.
862
+ writeScope: Array.isArray(draft.writeScope) ? draft.writeScope : null,
863
+ provider,
864
+ generate: makeLoopGenerate(provider),
865
+ // ONE number, both paid seams (the survey's and the declaration loop's) — the
866
+ // advertised ceiling and the enforced ceiling are the same ceiling
867
+ ceilingUsd: CEILING_USD,
868
+ // …and the two reporting seams. The library emits nothing itself (the
869
+ // `runJob` → `runPlan` shape): this runner owns the spine and the terminal.
870
+ onPhase, onCall,
871
+ // PRD item 33 M3 piece 4 (step S4) — the confirm turn. `isRepo: true` is
872
+ // safe unconditionally here: the manifest.kind !== 'repo' stop above
873
+ // (before the key is even read) already refused every non-repo source,
874
+ // so every path that reaches this call is a repo job. `langResult`
875
+ // travels through unchanged — its `ambiguous` shape is what the confirm
876
+ // turn's own $0 half (D7) asks about, before the scout.
877
+ ask, confirmGenerate, isRepo: true, langResult,
878
+ });
879
+ const authoredFile = writeOut('authored.json', authored);
880
+ emit('authored', { ok: authored.ok, stop: authored.stop, seedRef: authored.seedRef, cost: authored.cost, reds: authored.reds });
881
+
882
+ out(`authoring ${authored.ok ? 'OK' : 'NOT OK'} stop=${authored.stop ?? 'none'} seed=${authored.seedRef ?? 'unread'}`);
883
+ out(`cost ${costLine(authored.cost)}`);
884
+ out(`written ${authoredFile}`);
885
+ for (const l of fellBackLines(authored.authoring)) out(l);
886
+
887
+ // THE CONFIRM TURN'S OWN STOPS (PRD item 33 M3 piece 4, step S4) — neither is
888
+ // a refusal (`authored.refusal` is null on both) and neither is a red
889
+ // (`authored.reds` is empty on both), so without this they would otherwise
890
+ // fall through the generic "no refusal and no reds" line below. `author-end`
891
+ // still records the stop either way (the generic branch further down carries
892
+ // it through `stop: authored.stop` regardless) — this is only the FRIENDLIER
893
+ // console line, said once, in the person's own words.
894
+ if (authored.stop === 'confirm-abandoned' || authored.stop === 'confirm-restart') {
895
+ out(`\n${authored.stop.toUpperCase()} — the confirm turn did not produce a signed plan.`);
896
+ out(authored.stop === 'confirm-abandoned'
897
+ ? ' input ended before you answered — nothing was signed, nothing runs, and nothing beyond this line is written.'
898
+ : ' you chose to start over — rerun the interview from the beginning with the answers you want to change.');
899
+ }
900
+
901
+ // THE GOVERNANCE STOP, read out on BOTH paths. A money stop can land with a
902
+ // signable close already authored and measured (`ok:true` — the cap tripped
903
+ // before a LATER revise), and burying it in that case would let a run that ran
904
+ // out of money read as a run that simply finished. It is not an error and it is
905
+ // not a verdict on the close: nothing retries, nothing is rolled back, and every
906
+ // artifact the run paid for is on disk exactly where it was written.
907
+ if (authored.stop === 'cap-halt' || authored.stop === 'pricing-red') {
908
+ const c = authored.cost ?? {};
909
+ // ONE reading of what this stop MEANS, spent on the console AND on the spine.
910
+ // Two hand-spelled answers is two instruments, and this is exactly the pair
911
+ // that must not disagree: a `cap-halt` says the money is gone, a `pricing-red`
912
+ // says the meter went blind (F6), and they send the operator to different
913
+ // repairs — one raises a number, the other binds a priced provider.
914
+ const meaning = authored.stop === 'cap-halt'
915
+ ? 'not under cap — not "can\'t"' // the shipped vocabulary, spelled the way ralph spells it
916
+ : 'the spend cannot be SEEN, so the ceiling cannot govern it — a blind meter, not a spent wallet (F6)';
917
+ out(`\n${authored.stop.toUpperCase()} — the authoring pipeline stopped on the operator's ceiling, not on anything it read`);
918
+ out(` ceiling $${CEILING_USD}`);
919
+ out(` spent ${costLine(authored.cost)}`);
920
+ out(` the cap binds BETWEEN calls, so nothing was cut off mid-flight — everything paid for is in ${OUT}`);
921
+ out(authored.stop === 'cap-halt'
922
+ ? ' the stop IS the checkpoint: raise --budget and re-run, or read what is here and stop'
923
+ : ' unpriced is never free (F6) — a ceiling that cannot see the spend cannot enforce it, so the run stopped rather than spend blind');
924
+ // THE TYPE IS THE STOP. It used to be the literal 'cap-halt' on both arms, so a
925
+ // `pricing-red` was written down as a cap-halt with its real name demoted to a
926
+ // payload field. Every OTHER emitter in this tree keys the two apart — ralph and
927
+ // planrun emit `type:'cap-halt'` only ever with `category:'cap-halt'`, and a
928
+ // pricing-red rides its own name (run.js's escalation) — so this was the one
929
+ // site in the repo where the type and the category could disagree, and
930
+ // type-keyed slicing is precisely how F45 misread a shared log.
931
+ //
932
+ // Latent, not live: nothing reads the author spine by type today. The nearest
933
+ // reader is `classifyIncidents` (src/ledger.js), which sets `capHalted` on
934
+ // `type === 'cap-halt'` and would therefore have armed the capability-gap fuse
935
+ // — "the run cap-halted" — on a run whose wallet was never empty. Under its own
936
+ // name a pricing-red instead falls through every branch and is simply not
937
+ // counted, which is the FAIL-SAFE direction: uncounted, never miscounted.
938
+ //
939
+ // No falsy type can reach here: the `if` above narrows `authored.stop` to
940
+ // exactly these two strings, so a guard would be speculative code standing over
941
+ // an unreachable case rather than protection.
942
+ emit(authored.stop, {
943
+ category: authored.stop, meaning,
944
+ ceilingUsd: CEILING_USD, spentUsd: c.costUsd ?? null, knownUsd: c.knownUsd ?? null,
945
+ spendComplete: c.spendComplete ?? null,
946
+ });
947
+ }
948
+
949
+ if (!authored.ok) {
950
+ // Verbatim, and in full. A refusal is COUNTED demand against bareloop's own
951
+ // catalogue (the `request-red` admission path) — narrating around it destroys
952
+ // the evidence the verdict-classes rung waits on.
953
+ if (authored.refusal) {
954
+ const r = authored.refusal;
955
+ out(`\nREFUSED (${r.kind}) verb=${r.verb ?? 'none'} path=${r.path}`);
956
+ out(r.detail);
957
+ for (const o of r.options ?? []) out(` · ${o}`);
958
+ if (r.red) out(` red: ${JSON.stringify(r.red)}`);
959
+ for (const e of refusalEvents(r)) emit(e.type, e);
960
+ }
961
+ for (const red of authored.reds ?? []) {
962
+ out(`\nRED ${red.code} at ${red.path}\n${red.detail}`);
963
+ emit('job-red', red);
964
+ }
965
+ if (!authored.refusal && !(authored.reds ?? []).length) out(`\nNo refusal and no reds — the stop is "${authored.stop}" on its own (read ${authoredFile}).`);
966
+ out(`\nSTOPPED at authoring. Nothing was assembled, nothing was validated, nothing was signed.`);
967
+ emit('author-end', { outcome: 'not-authored', stop: authored.stop });
968
+ exitCode = 1;
969
+ } else {
970
+ // PRD item 33 M3, ruling 5 addendum (step S4): the confirm turn drafts the
971
+ // signed goal sentence and the person confirms/fixes it there — the
972
+ // interview's own separate goal question is what this REPLACES (D2's
973
+ // "the confirmed goal replaces it"). `goal` stays an OPERATOR field
974
+ // (D2: it does not join `AUTHORED_SPEC_FIELDS`), so it is set on the
975
+ // DRAFT here, before assembling, exactly where the interview's own
976
+ // answer would otherwise have landed. Absent only if this authoring run
977
+ // predates the confirm turn's own `ask` wiring (`authored.confirmed` is
978
+ // `null` for every caller that runs no confirm turn) — the draft's own
979
+ // goal (if any) then stands untouched.
980
+ if (authored.confirmed?.goal) {
981
+ draft.goal = redactSecrets(String(authored.confirmed.goal));
982
+ }
983
+ // ── 2. the operator's half + the authored half → one spec ──────────────────
984
+ // an `ok:true` authoring result always carries a verdictType (the class was
985
+ // admitted and the pipeline authored a close for it) — a real runtime guard,
986
+ // narrowing this honestly rather than casting past what tsc can prove, the
987
+ // same idiom src/userrun.js's own `providerEntry` narrowing already uses.
988
+ if (authored.verdictType === null) throw new Error('authorCloseForJob returned ok:true with no verdictType — an internal invariant broke');
989
+ // spread into a fresh object (never a cast) so the narrowed, non-null
990
+ // verdictType above is what the OBJECT's own type carries at this call
991
+ // site too — tsc's narrowing of a single property access does not, on
992
+ // its own, change what `assembleSpec(draft, authored)` sees for the
993
+ // object as a whole.
994
+ const spec = assembleSpec(draft, { ...authored, verdictType: authored.verdictType });
995
+ const specFile = writeOut('resolved-spec.json', spec);
996
+ out(`\nverdictType ${authored.verdictType} (the person's own pick, validated — never inferred)`);
997
+ // F87: the goal and the stages that judge it are ONE reading. This surface used to
998
+ // print the declaration alone, which shows a signer everything the close measures
999
+ // and nothing about whether the goal ever said so. Rendered from the RESOLVED spec
1000
+ // — the bytes that get hashed — and out of `scripts/author-readout.mjs`, because
1001
+ // this block is otherwise reachable only after a paid scout and a paid model call,
1002
+ // and a readout no test can reach is a readout nothing checks.
1003
+ for (const l of declarationLines(spec)) out(l);
1004
+ // …and, for a close that JUDGES, the two artifacts the judge is signed with.
1005
+ // NAMED DEFERRAL, said out loud rather than implied: this script's ONE
1006
+ // interactive seam is the confirm turn above (PRD item 33 M3 piece 4,
1007
+ // step S4) — it does not ALSO offer the D5 fix step here, so the
1008
+ // proposal is signed AS PROPOSED. The library seam exists
1009
+ // (`authorCloseForJob({signerFix})`) and the interactive surface for IT
1010
+ // is the interview's and the UI's (N6), which is the same split the
1011
+ // guard battery already lives under here: shown, not edited at this surface.
1012
+ const rubric = rubricLines(spec);
1013
+ if (rubric.length) {
1014
+ for (const l of rubric) out(l);
1015
+ out(` source ${authored.judged?.source ?? 'unknown'}`
1016
+ + (authored.judged?.source === 'proposal' ? ' (proposed and stored unedited — this script offers no fix step)' : ''));
1017
+ for (const s of authored.judged?.scrubbed ?? []) out(` MASKED ${s.path} — the stored bytes differ from what was typed`);
1018
+ }
1019
+ out(`written ${specFile}`);
1020
+
1021
+ // `shellCapUsd` COUPLES to the spec's own budget, exactly as run-u does it at the
1022
+ // launch site. Left off, both gates below judge against validateJob's default
1023
+ // ceiling of 2 — a silent second ceiling, and the worst-placed one there is: the
1024
+ // draft's `budgetUsd` is the operator's own signed input, so a $4 draft would be
1025
+ // paid for in full (interview, scout, declaration) and only THEN redded on a
1026
+ // number nobody set. The advertised budget and the enforced budget are the same
1027
+ // number; the agent may tighten and never widen, and neither may this script.
1028
+ const jv = validateJob(spec, { shellCapUsd: spec.budgetUsd });
1029
+ emit('job-validate', { ok: jv.ok, reds: jv.reds });
1030
+ if (!jv.ok) {
1031
+ out(`\nSPEC INVALID — ${jv.reds.length} red(s):`);
1032
+ for (const red of jv.reds) out(` ${red.code} at ${red.path}: ${red.detail}`);
1033
+ out('\nSTOPPED at validateJob. The draft and the authored close do not compose into a runnable spec.');
1034
+ emit('author-end', { outcome: 'spec-invalid' });
1035
+ exitCode = 1;
1036
+ } else {
1037
+ // ── 3. D9's gates. Nothing here judges the close; it measures it. ────────
1038
+ //
1039
+ // THE JUDGE SEAM, wired only when the close actually judges. It is a
1040
+ // A SEPARATE provider instance for the judge: its tier is never the
1041
+ // drafter's model and never agent-selectable, and §4.2's safety argument is
1042
+ // worth exactly as much as the tier its injection evidence was measured on.
1043
+ // Absent it, `prepareSigning` refuses the close as a wiring gap rather than
1044
+ // signing an ungraded ruler.
1045
+ const judges = closeJudges(spec.closeDecl);
1046
+ // THE JUDGE, RESOLVED (PRD item 32.1) — the job's OWN worker provider and
1047
+ // model by default, a signed `judge` override when the spec names one.
1048
+ // Resolved from the ASSEMBLED SPEC through the same `resolveJobJudge` call
1049
+ // as `draftJudge` above; `provider`/`model`/`judge` reached here unchanged
1050
+ // from the draft, so this is the identical pair — the calibration gate
1051
+ // STAMPS the graded set with whatever this resolves to, so the identity
1052
+ // that certifies the floor here must be the identity the close will later
1053
+ // refuse to grade without (and the identity `scripts/run-u.mjs` resolves).
1054
+ const judge = judges
1055
+ ? resolveJobJudge(spec, PROVIDER_NAME, resolveWorkerModel)
1056
+ : null;
1057
+ // F181 — the judge key is required exactly when `judges` is true (this
1058
+ // script has no presence check on it today; adding one is out of this
1059
+ // finding's scope). What this door DOES owe, the same as the worker
1060
+ // key above: a resolved value that IS present but carries a shape an
1061
+ // HTTP header cannot refuses here, before the calibration gate spends
1062
+ // anything, rather than crashing mid-call.
1063
+ // `if (judge)` rather than `if (judges)`: identical at runtime (`judge`
1064
+ // is non-null exactly when `judges` is true, by the ternary just above)
1065
+ // but this form is what actually narrows `judge` for tsc below, honestly,
1066
+ // rather than a cast past what it can prove.
1067
+ if (judge) {
1068
+ const judgeKeyValue = judgeKeyFor(judge.provider);
1069
+ const judgeKeyProblem = judgeKeyValue ? apiKeyProblem(judgeKeyValue) : null;
1070
+ if (judgeKeyProblem) {
1071
+ const judgeEnvName = env.JUDGE_API_KEY ? 'JUDGE_API_KEY' : resolveProvider(judge.provider).envKey;
1072
+ err(`${judgeEnvName} ${judgeKeyProblem} — refusing rather than crashing mid-call (never trimmed or repaired; fix the value at its source)`);
1073
+ // F186 — the scout above already ran and may have left its own gate
1074
+ // audit in the tree. Under `process.exit()` this path used to skip
1075
+ // the finally block below entirely, so the audit was archived here
1076
+ // explicitly. A thrown ExitSignal now DOES reach the finally block
1077
+ // (unlike process.exit()) — archiveGateAudit() runs again there —
1078
+ // but the call stays here too: it is idempotent (`gateAuditArchived`
1079
+ // guards it), and this keeps the archive from ever depending on the
1080
+ // finally block running first, the same defensive reasoning F186
1081
+ // was written under.
1082
+ archiveGateAudit();
1083
+ throw new ExitSignal(2);
1084
+ }
1085
+ }
1086
+ // F190 — `baseUrl` rides along only when the judge is the SAME provider
1087
+ // as the author/worker: a spec's `baseUrl` is the AUTHOR's endpoint, and
1088
+ // handing it to a different vendor's client is the silent-misconfiguration
1089
+ // class `endpointKey` exists to prevent (a DeepSeek key sent to the
1090
+ // openai-api default host, e.g.). Routed through `buildRunnerProviders`
1091
+ // (the ONE owner of this construction — `src/cli.js` and, above,
1092
+ // this file's own author provider) rather than a hand-rolled
1093
+ // `makeProvider` call: the `providerName`/`apiKey`/`model`/`tierModels`/
1094
+ // `baseUrl` half repeats the author's own identity (so its `provider`
1095
+ // half of the return, unused here, is a throwaway — never a second live
1096
+ // instance of the author's client), and the `judge*` half is this run's
1097
+ // OWN resolved judge — the identical conditional `scripts/run-u.mjs`'s
1098
+ // own call already applies.
1099
+ // an if/else, not the equivalent ternary — the ternary's own ok-branch
1100
+ // object literal did not narrow `judge` for tsc; this form does, the
1101
+ // same real-narrowing reasoning as the `if (judge)` above.
1102
+ let judgeProvider = null;
1103
+ if (judge) {
1104
+ judgeProvider = buildRunnerProviders({
1105
+ providerName: PROVIDER_NAME, apiKey, model: MODEL, tierModels: providerEntry.tiers, baseUrl,
1106
+ judgeApiKey: judgeKeyFor(judge.provider), judgeModel: judge.model, judgeProviderName: judge.provider,
1107
+ judgeBaseUrl: judge.provider === PROVIDER_NAME ? baseUrl : undefined,
1108
+ }).judgeProvider;
1109
+ }
1110
+ if (judge) {
1111
+ out(`\ncalibration gate — REAL judge calls at ${judge.model} on ${judge.provider}, one per case plus the injection battery.`);
1112
+ out(' this is the only gate that spends money, and it runs after every free one.');
1113
+ }
1114
+ const signing = await prepareSigning({
1115
+ spec, workdir: SOURCE, seedRef: authored.seedRef, timeoutMs: TIMEOUT_MS,
1116
+ // gate 1a re-runs the job validator inside prepareSigning — same coupling, or
1117
+ // the spec that just passed above would fail the gate that signs it
1118
+ shellCapUsd: spec.budgetUsd,
1119
+ // …and the SAME operator ceiling the scout and the declaration loop ran under,
1120
+ // with everything they spent folded in. The gate is this run's third and
1121
+ // largest paid seam; without both halves the advertised budget and the
1122
+ // enforced budget are two different numbers, and re-invoking a seam under one
1123
+ // number would silently widen it.
1124
+ ceilingUsd: CEILING_USD,
1125
+ priorCalls: [...metered],
1126
+ judgeLoop: judgeProvider ? (o) => defaultJudgeLoop({ provider: judgeProvider, system: o.system }) : null,
1127
+ // the seam's other half (PRD item 32.1): the graded set is STAMPED with
1128
+ // this, and the close later refuses to grade under any other identity.
1129
+ judgeModel: judge?.model ?? null,
1130
+ // the gate's spend joins the run's ONE metered list, under the judge call's
1131
+ // own label — a close's calibration is money like any other money (F12)
1132
+ onJudgeCost: (c) => onCall({ label: `${c.label}:${c.id}`, costUsd: c.costUsd, unpricedRounds: c.unpricedRounds }),
1133
+ });
1134
+ const signingFile = writeOut('signing.json', signing);
1135
+ emit('signing', { ok: signing.ok, specHash: signing.specHash, seedRef: signing.seedRef, gates: signing.gates });
1136
+
1137
+ const g = signing.gates ?? {};
1138
+ out('\ngates');
1139
+ out(` 1 declaration ${g.declaration?.ok ? 'PASS' : 'FAIL'} grounded=${g.declaration?.grounded ?? '-'}${g.declaration?.scoped ? ` scoped=${JSON.stringify(g.declaration.scoped)}` : ''}`);
1140
+ out(` 2 precheck ${g.precheck === null ? 'not reached' : (g.precheck.ok ? 'PASS — every stage ran' : `FAIL — ${g.precheck.stops.length} stage(s) could not run`)}`);
1141
+ // ruling 3: the judged-ONLY close clears this gate on its calibration, so the
1142
+ // line says PASS and names the proof — printing FAIL beside a signable spec
1143
+ // would be the readout disagreeing with the gate it reports on
1144
+ out(` 3 seed verdict ${g.seedVerdict === null
1145
+ ? 'not reached'
1146
+ : (g.seedVerdict.ok
1147
+ ? `PASS — work red at seed: ${g.seedVerdict.workRed.join(', ')}`
1148
+ : (g.seedVerdict.satisfiedBy === 'calibration'
1149
+ ? 'PASS via the CALIBRATION gate — this close\'s only work stage is judged'
1150
+ : 'FAIL — no work stage is red at the seed'))}`);
1151
+ if (g.seedVerdict) {
1152
+ out(` red at seed: ${g.seedVerdict.redAtSeed.join(', ') || '(none)'}`);
1153
+ out(` green at seed: ${g.seedVerdict.greenAtSeed.join(', ') || '(none)'}`);
1154
+ // ruling 3: a judged-ONLY close clears gate 3 on its calibration instead,
1155
+ // and the surface SAYS which proof carried it rather than leaving a reader
1156
+ // to wonder why an empty workRed list passed
1157
+ if (g.seedVerdict.satisfiedBy === 'calibration') {
1158
+ out(' a judged stage skips the seed read (ruling 8), so this close\'s proof that it CAN fail is');
1159
+ out(' the graded calibration set below — signed cases it must red as well as ones it must pass.');
1160
+ }
1161
+ }
1162
+ if (judges) for (const l of calibrationLines(g.calibration)) out(l);
1163
+
1164
+ /** the seed evidence a user READS to decide whether this close measures their
1165
+ * job. `value`/`baseline` print as `unknown` when absent — never as 0. */
1166
+ const row = (/** @type {any} */ r) => {
1167
+ out(` ${r.verdict.toUpperCase().padEnd(16)} ${r.stage} [${r.kind}] value=${r.value ?? 'unknown'} baseline=${r.baseline ?? 'unknown'}${r.baselineSource ? ` (${r.baselineSource})` : ''}`);
1168
+ for (const l of (r.gap ?? []).slice(0, 8)) out(` | ${l}`);
1169
+ if ((r.gap ?? []).length > 8) out(` | … ${r.gap.length - 8} more gap line(s) — full text in ${signingFile}`);
1170
+ };
1171
+ if ((signing.work ?? []).length) { out('\nwork stages at the seed'); for (const r of signing.work) row(r); }
1172
+ if ((signing.guards ?? []).length) { out('\nguard stages at the seed'); for (const r of signing.guards) row(r); }
1173
+ if ((signing.stops ?? []).length) { out('\nstages that could NOT RUN (a broken instrument is a casualty, never a verdict)'); for (const r of signing.stops) row(r); }
1174
+
1175
+ if (signing.refusal) {
1176
+ const r = signing.refusal;
1177
+ out(`\nREFUSED (${r.kind}) verb=${r.verb ?? 'none'} path=${r.path}`);
1178
+ out(r.detail);
1179
+ for (const o of r.options ?? []) out(` · ${o}`);
1180
+ for (const e of refusalEvents(r)) emit(e.type, e);
1181
+ }
1182
+ for (const red of signing.reds ?? []) {
1183
+ out(`\nRED ${red.code} at ${red.path}\n${red.detail}`);
1184
+ emit('job-red', red);
1185
+ }
1186
+
1187
+ out(`\nwritten ${signingFile}`);
1188
+ const hash = jobSpecHash(spec);
1189
+ // A hash printed beside a refusal reads like something to sign. It is the
1190
+ // record of WHICH bytes were measured, and on a failed gate it says so.
1191
+ out(`spec hash ${hash}${signing.ok ? '' : ' (for the record — this spec did NOT clear the gates and is not signable)'}`);
1192
+ // The hash prepareSigning returns is over the VALIDATED spec; this one is
1193
+ // over the assembled bytes. They are the same object by construction, and a
1194
+ // divergence would mean the validator normalized something — say it rather
1195
+ // than pick one.
1196
+ if (signing.specHash && signing.specHash !== hash) {
1197
+ out(` NOTE prepareSigning hashed ${signing.specHash} — the validator normalized the spec; sign the resolved one.`);
1198
+ }
1199
+ // THE WHOLE RUN's spend, off the ONE metered list — which now has two
1200
+ // populations in it. Gates 1-3 spend no tokens (they run commands); gate 4
1201
+ // buys a real judge call per case, so a line saying "the gates spend no
1202
+ // tokens" would have been false the moment a judged close reached here.
1203
+ out(`authoring ${costLine(authored.cost)}`);
1204
+ out(`total cost ${costLine(costSoFar())}${judges ? ' (includes the calibration gate\'s judge calls)' : ' (gates 1-3 spend no tokens — they run commands)'}`);
1205
+
1206
+ if (!signing.ok) {
1207
+ out('\nSIGNING NOT PREPARED — the close did not clear D9\'s gates. Nothing was signed and nothing was run.');
1208
+ emit('author-end', { outcome: 'gates-failed', specHash: hash });
1209
+ exitCode = 1;
1210
+ } else {
1211
+ out('\nSIGNING PREPARED — NOT SIGNED. This script stops here, by design.');
1212
+ out(` the resolved spec is ${specFile} and it hashes to ${hash}`);
1213
+ out(' read the seed evidence above; if the close measures your job, the signature is yours to give.');
1214
+ for (const l of openQuestionLines(authored.confirmed)) out(` ${l}`);
1215
+ for (const l of answeredQuestionLines(authored.confirmed)) out(` ${l}`);
1216
+ // F185 — the loose end this fix closes: until now this screen named no
1217
+ // command that actually RUNS the spec it just wrote, so reaching a
1218
+ // running job from here needed a developer to hand-add a JOBS row and
1219
+ // copy this file into jobs/. scripts/run-u.mjs now accepts `--spec
1220
+ // <path>` and reads this SAME prepared copy's own source manifest for
1221
+ // the workdir and seed — so the command below is the whole of what a
1222
+ // person needs to run their own job, nothing left to hand off.
1223
+ // `providerEntry.envKey` (never a hardcoded ANTHROPIC_API_KEY) is the
1224
+ // same F187 rule scripts/run-u.mjs's own hint already follows.
1225
+ out('\nTo run it (the same signature and gates as any other job — nothing here bypasses them):');
1226
+ out(` ${providerEntry.envKey}=... node scripts/run-u.mjs --spec ${specFile} --approve ${hash}`);
1227
+ emit('author-end', { outcome: 'prepared', specHash: hash });
1228
+ }
1229
+ }
1230
+ }
1231
+ }
1232
+ } catch (authorErr) {
1233
+ // an ExitSignal escaping the inner try above is a delibarate early stop
1234
+ // (an operator/config refusal, a demand, or the judge-key door), never a
1235
+ // crash — this handler is for a genuine unexpected throw, so ExitSignal
1236
+ // passes straight through to the OUTER catch (below) that turns it back
1237
+ // into a return code, exactly the shape src/userrun.js's execute() uses.
1238
+ if (authorErr instanceof ExitSignal) throw authorErr;
1239
+ // THE OPERATOR'S COPY FIRST, and whole — the same bytes the unhandled rejection
1240
+ // used to print, on the same stream. First because it must not depend on the two
1241
+ // writes below succeeding: a diagnosis that reaches the person only if the disk
1242
+ // is writable is a diagnosis with a dependency nobody asked for.
1243
+ //
1244
+ // F191 — the net now starts right after `author-start`, before ANY paid call
1245
+ // (the plain-folder stop above spends nothing, ever). "died inside the paid
1246
+ // span" was already false for that path and would be false again for any
1247
+ // future $0-only stop this net comes to cover — `metered.length` (hoisted
1248
+ // above the try for exactly this reason) says which happened, honestly,
1249
+ // rather than a fixed claim baked into the message.
1250
+ err(`\nCRASHED — the authoring run died ${metered.length ? 'inside the paid span' : 'before any paid call'}. Nothing was signed, and nothing was retried.`);
1251
+ // the RAW error, with its full stack — deliberately NOT routed through the
1252
+ // injected err()/stderr above: Node's console.error formats an Error object
1253
+ // specially (the full multi-line stack), and this is the one place that
1254
+ // exact, byte-identical formatting is the point (the same bytes an
1255
+ // unhandled rejection used to print). Every other line in this handler
1256
+ // goes through err() as normal.
1257
+ console.error(authorErr);
1258
+ // ...and the spine's copy, through the ONE persist boundary (`crashRecord` →
1259
+ // `scrubRaw` → the same `SECRET_PATTERNS` inventory the validator reds on). A
1260
+ // stack is exactly the string most likely to carry a live credential out of a
1261
+ // process, and this file outlives the run.
1262
+ //
1263
+ // BEST-EFFORT, and that is the point: a crash handler that crashes destroys the
1264
+ // report it was built to make (F70). If the record cannot be built or the append
1265
+ // fails, that failure is said out loud and the original error above still stands.
1266
+ try {
1267
+ emit('author-crash', crashRecord(authorErr));
1268
+ // The run ENDED, and it ended a way no other arm ends. `author-end` is the one
1269
+ // record that says a run stopped AT ALL, so a crash that omits it leaves a spine
1270
+ // that reads as still-running. The detail is NOT repeated here — it is on the
1271
+ // `author-crash` record above, because two hand-spelled copies of one fact are
1272
+ // two instruments, and this file has already paid once for letting a pair of
1273
+ // those disagree (the cap-halt/pricing-red type).
1274
+ emit('author-end', { outcome: 'crashed' });
1275
+ err(` written to the spine as author-crash + author-end{outcome:'crashed'}`);
1276
+ } catch (spineErr) {
1277
+ err(` AND THE SPINE COULD NOT BE WRITTEN: ${/** @type {any} */ (spineErr)?.message ?? spineErr}`);
1278
+ err(' the error above is the only record of this run — copy it before it scrolls');
1279
+ }
1280
+ // no `err(\` spine ${spineFile}\`)` here — this catch falls through to the
1281
+ // shared tail (`out(\`\nspine ${spineFile}\`)\`, below), which runs on
1282
+ // EVERY exit path including this one (nothing in finally/the tail skips it
1283
+ // when the catch already set exitCode). A second, catch-local print here
1284
+ // used to duplicate that line on every crash; ONE owner (the tail) now
1285
+ // prints it, same as the refusal/failed-gate/signed-readout paths always
1286
+ // did. (The `err(' spine …')` in the SIGINT/SIGTERM/SIGHUP handler above
1287
+ // is a different path — a real signal re-raises and never reaches the
1288
+ // tail, so that one stays as the sole print for a killed run.)
1289
+ // 4, distinct from 1 (a refusal or a failed gate), 2 (operator/config) and 3 (a
1290
+ // leak). A crash is none of those: it is the run failing to reach a verdict at
1291
+ // all, and sharing an exit code with a refusal would file a bug as a result.
1292
+ exitCode = 4;
1293
+ } finally {
1294
+ // `rl.close()` here, not inline at every exit path above: this file has
1295
+ // MANY of those (a refusal, a failed gate, a signed readout, a crash), and
1296
+ // a reader left open on ANY of them is a process that never lets go of
1297
+ // stdin. BEST-EFFORT (F70's rule again) — closing the interactive seam must
1298
+ // never take the readout it follows down with it.
1299
+ try { rl?.close(); } catch { /* see onPhase */ }
1300
+ // F186 — same reasoning, same place: EVERY exit path that reaches this
1301
+ // finally (a refusal, a failed gate, a signed readout, or the crash catch
1302
+ // above) may have run the scout, and the scout's gate audit must never be
1303
+ // left in the patient tree for a later run to inherit. archiveGateAudit()
1304
+ // is idempotent, so this is safe even on the one path (the judge-key
1305
+ // refusal above) that already called it before exiting early.
1306
+ archiveGateAudit();
1307
+ // a real signal already removed all of these itself, on its own way out
1308
+ // (`process.removeAllListeners(sig)`, above); this is only for the
1309
+ // ordinary (non-killed) path, so `main` stays safe to call more than
1310
+ // once in one process — see the comment where sigHandlers is built.
1311
+ try { for (const [sig, handler] of sigHandlers) process.removeListener(sig, handler); } catch { /* see onPhase */ }
1312
+ }
1313
+
1314
+ // The hard line, on the artifacts this run just wrote. Count and PATH only —
1315
+ // echoing a matched secret to stdout is the same leak, one hop on.
1316
+ //
1317
+ // (discovered live, item 4 of the 2026-09-21 /debrief fix-all-4 batch): a
1318
+ // crash mid-writeOut can leave a path here naming something that EXISTS but
1319
+ // is NOT the file this run wrote (e.g. a pre-existing directory the write
1320
+ // tripped on) — `existsSync` alone is true for a directory, and
1321
+ // `readFileSync` on one throws EISDIR, uncaught, AFTER the try/catch above
1322
+ // already handled the real crash — a second, unrelated crash stealing the
1323
+ // first one's honest report. `statSync(f).isFile()` scopes this scan to
1324
+ // what was actually written, never what merely exists at that path.
1325
+ const written = [spineFile, join(OUT, 'authored.json'), join(OUT, 'resolved-spec.json'), join(OUT, 'signing.json')]
1326
+ .filter((f) => existsSync(f) && statSync(f).isFile());
1327
+ const leaks = written.flatMap((f) => scanSecrets(readFileSync(f, 'utf8')).map(() => f));
1328
+ if (leaks.length) {
1329
+ out(`\nLEAK: ${leaks.length} secret-shaped string(s) across ${new Set(leaks).size} written file(s) — the hard line is broken; do NOT sign this spec`);
1330
+ // distinct from 2 (operator/config) and 1 (a refusal or a failed gate). It also
1331
+ // OVERRIDES a 4 set by the crash catch above, deliberately: a secret sitting in a
1332
+ // file is the harder line of the two, and the crash keeps both of its own louder
1333
+ // channels — the whole error on stderr and its own `author-crash` spine record.
1334
+ exitCode = 3;
1335
+ }
1336
+ out(`\nspine ${spineFile}`);
1337
+ // F71 — never process.exit() after output: exit() can discard queued stdout and
1338
+ // produce a clean-looking short readout of a run that said more than that.
1339
+
1340
+ return exitCode;
1341
+ } catch (/** @type {any} */ e) {
1342
+ if (e instanceof ExitSignal) return e.code;
1343
+ throw e;
1344
+ }
1345
+ }