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.
- package/CHANGELOG.md +114 -0
- package/bareloop.context.md +245 -33
- package/package.json +1 -1
- package/src/authorreadout.js +323 -0
- package/src/authorrun.js +1345 -0
- package/src/behaviour.js +5 -2
- package/src/cli.js +190 -8
- package/src/index.js +13 -1
- package/src/interviewrun.js +842 -0
- package/src/panel/index.html +1910 -0
- package/src/panel/server.js +1527 -0
- package/src/planrun.js +1 -1
- package/src/replay.js +632 -13
- package/src/replayio.js +159 -0
- package/src/reuse.js +1 -1
- package/src/run.js +1 -1
- package/src/runlist.js +281 -0
- package/src/u-patient.js +73 -0
- package/src/u-readout.js +451 -0
- package/src/userrun.js +2299 -0
- package/types/authorreadout.d.ts +172 -0
- package/types/authorrun.d.ts +37 -0
- package/types/behaviour.d.ts +5 -3
- package/types/cli.d.ts +2 -1
- package/types/index.d.ts +2 -1
- package/types/interviewrun.d.ts +46 -0
- package/types/panel/server.d.ts +265 -0
- package/types/planrun.d.ts +2 -2
- package/types/replay.d.ts +104 -6
- package/types/replayio.d.ts +93 -0
- package/types/reuse.d.ts +2 -2
- package/types/run.d.ts +2 -2
- package/types/runlist.d.ts +112 -0
- package/types/u-patient.d.ts +39 -0
- package/types/u-readout.d.ts +247 -0
- package/types/userrun.d.ts +141 -0
package/src/authorrun.js
ADDED
|
@@ -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
|
+
}
|