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/userrun.js
ADDED
|
@@ -0,0 +1,2299 @@
|
|
|
1
|
+
// The person-path run — the WHOLE flow (`--job`/`--spec`, `--resume`, `--door`)
|
|
2
|
+
// lifted out of `scripts/run-u.mjs` into one library module (PRD PANEL-BUILD.md
|
|
3
|
+
// P0; hamr: "i think run-u one libary is better to prevent drift", then
|
|
4
|
+
// "agreed, one module three thin doors"). One internal engine (`execute`,
|
|
5
|
+
// below) and three thin named doors onto it — `startRun`, `resumeRun`,
|
|
6
|
+
// `answerDoor` — replacing the flag-driven entry `scripts/run-u.mjs` used to
|
|
7
|
+
// be. `scripts/run-u.mjs` is now a thin adapter over `main(argv, deps)` here,
|
|
8
|
+
// the same ~10-line shape `bin/bareloop.mjs` already is over `src/cli.js`.
|
|
9
|
+
//
|
|
10
|
+
// This is a LIFT, not a rewrite: the orchestration below is
|
|
11
|
+
// `scripts/run-u.mjs`'s own body, unchanged in its logic — every
|
|
12
|
+
// `console.log`/`console.error` became `out`/`err`, and every
|
|
13
|
+
// `process.exit(n)` became `throw new ExitSignal(n)` (a library function may
|
|
14
|
+
// never call `process.exit` itself). The semantics it calls (`runJob`,
|
|
15
|
+
// `answerReviewDoor`, `readResume`, `replayRun`) were already library before
|
|
16
|
+
// this move and are untouched; `src/replay.js` stays exactly where it is and
|
|
17
|
+
// is not a door.
|
|
18
|
+
//
|
|
19
|
+
// U — the user-mode e2e. ONE run: one sentence of problem, a budget, a clock, one
|
|
20
|
+
// close. No arms, no operator-authored checks, no hand-solving, no retry logic —
|
|
21
|
+
// a run that dies comes back to hamr with the cause (standing rule, 2026-07-26).
|
|
22
|
+
//
|
|
23
|
+
// Acceptance (hamr, LAYERS §4): the workflow need not green everything — it must
|
|
24
|
+
// green ONE job end to end. That green GRADUATES the bridge: the plan the agent
|
|
25
|
+
// authored is preserved from the spine as a reusable artifact, and the next run of
|
|
26
|
+
// this shape reuses and fine-tunes it rather than starting cold.
|
|
27
|
+
import { readFileSync, writeFileSync, appendFileSync, mkdirSync, existsSync, renameSync, rmSync, readdirSync } from 'node:fs';
|
|
28
|
+
import { createRequire } from 'node:module';
|
|
29
|
+
import { fileURLToPath } from 'node:url';
|
|
30
|
+
import { execFileSync, spawn } from 'node:child_process';
|
|
31
|
+
import { join, resolve, dirname } from 'node:path';
|
|
32
|
+
import { runJob } from './run.js';
|
|
33
|
+
import { jobSpecHash, resolveWorkerModel, validateJob } from './job.js';
|
|
34
|
+
import { readShimArm } from './readshim.js';
|
|
35
|
+
import { closeStagesOf } from './plan.js';
|
|
36
|
+
import { resolveCloseTimeoutMs } from './closetimeout.js';
|
|
37
|
+
import { makeSpine } from './spine.js';
|
|
38
|
+
// PRD item 33/M2 — the source front door. `readSourceManifest` finds the
|
|
39
|
+
// per-run instance a `prepareSource` patient carries beside its tree (never
|
|
40
|
+
// signed into the job spec — a job is a repeatable SHAPE, the source/
|
|
41
|
+
// destination a per-run value, exactly like `bareloop run <bundle> --repo
|
|
42
|
+
// <path>` varies the repo without touching the bundle's signature).
|
|
43
|
+
import { readSourceManifest, frontDoorFromManifest, proveDestination, copyOut } from './source.js';
|
|
44
|
+
import { scanSecrets, redactSecrets } from './validate.js';
|
|
45
|
+
import { runBehaviour, formatBehaviour } from './behaviour.js';
|
|
46
|
+
// the three doors' SEMANTICS live in the library; this script surfaces them
|
|
47
|
+
import { normalizeHumanRuling, resolveHumanRuling, closeJudges, judgedStages } from './kinds.js';
|
|
48
|
+
// SOFTGREEN — the judged stage's PINNED tier. The constant is the library's; a
|
|
49
|
+
// spelling here would be a second pin that can drift from the one the gate calibrated.
|
|
50
|
+
import { resolveJudge } from './judged.js';
|
|
51
|
+
// --resume reads the halted run's own spine back through the SAME reader the reuse
|
|
52
|
+
// path uses (never a second one) and keeps its patient the way it left it.
|
|
53
|
+
import { readResume, resumeTreeGate, checkpointAgeGate, writeRunGreenRow, CHECKPOINT_OUTCOMES, PAUSE_TTL_MS } from './reuse.js';
|
|
54
|
+
// PRD item 28's provider factory (src/providers.js): the WORKER provider's
|
|
55
|
+
// ctor/envKey/tier-table/param-gating come from here now — one seam instead
|
|
56
|
+
// of an `if (provider === …)` scattered across this script. PRD item 32.2
|
|
57
|
+
// moved the JUDGE onto this SAME factory: it used to be separately pinned
|
|
58
|
+
// below to a hardcoded Anthropic constructor at the judged tier because the
|
|
59
|
+
// judge could only ever be Claude — now that its identity is RESOLVED
|
|
60
|
+
// (`resolveJudge`, imported above) rather than pinned, building it through a
|
|
61
|
+
// second bespoke constructor would be a second seam for the same job the
|
|
62
|
+
// worker's already goes through. `tests/reviewdoor-u.test.js` greps this
|
|
63
|
+
// file's own source to prove THIS runner actually wires the resolved judge
|
|
64
|
+
// through `makeProvider` into `runJob` (not just that the library can).
|
|
65
|
+
import { resolveProvider, makeProvider, probeWarningLines, apiKeyProblem } from './providers.js';
|
|
66
|
+
import { loadRegistry, quarantinesCredit } from './bridges.js';
|
|
67
|
+
import { HITL_PAUSE } from './declaredclose.js';
|
|
68
|
+
// the REVIEW DOOR (module 8): the library opens it on the run's own spine, and this
|
|
69
|
+
// is the seam that answers one. Same rulebook, one level out.
|
|
70
|
+
import { answerReviewDoor, doorRecordOf, doorAgeGate } from './reviewdoor.js';
|
|
71
|
+
// the cold reset, shared with the battery drivers so "cold" has one spelling
|
|
72
|
+
import { coldReset, moveStaleGateAudit } from './u-patient.js';
|
|
73
|
+
// PANEL-BUILD.md P1 — the one run list (`~/.config/bareloop/runs.jsonl`).
|
|
74
|
+
import { appendRun } from './runlist.js';
|
|
75
|
+
// the banner's wall arithmetic, extracted so it is reachable by a test (F83): the
|
|
76
|
+
// end-of-run readout sits past the approval gate, so nothing could ever drive it here
|
|
77
|
+
import { wallLine, doomedResume, deathAtOf, evidencePackage, doorLines, resumeAtLines, reviewDoorPackage, runDoorLines, tokensLine, doorTimingRedLines } from './u-readout.js';
|
|
78
|
+
|
|
79
|
+
const require = createRequire(import.meta.url);
|
|
80
|
+
const { AnthropicProvider } = require('bare-agent/providers');
|
|
81
|
+
|
|
82
|
+
const JOBS = {
|
|
83
|
+
'aurora-spawner': {
|
|
84
|
+
spec: 'aurora-u-spawner-types.json',
|
|
85
|
+
workdir: '/home/hamr/PycharmProjects/bareloop-patients/aurora-u',
|
|
86
|
+
spine: 'aurora-u-bareloop',
|
|
87
|
+
seed: 'd661e507c5cd0981368d90ed3e3abf6e2bb9ed18',
|
|
88
|
+
},
|
|
89
|
+
'litectx-types': {
|
|
90
|
+
spec: 'litectx-u-types.json',
|
|
91
|
+
workdir: '/home/hamr/PycharmProjects/bareloop-patients/litectx-u',
|
|
92
|
+
spine: 'litectx-u-bareloop',
|
|
93
|
+
seed: '96813a43bbcbac6a808ff610c6751a8736e2903e',
|
|
94
|
+
},
|
|
95
|
+
'pulselog-types': {
|
|
96
|
+
spec: 'pulselog-u-types.json',
|
|
97
|
+
workdir: '/home/hamr/PycharmProjects/bareloop-patients/pulselog-u',
|
|
98
|
+
spine: 'pulselog-u-bareloop',
|
|
99
|
+
seed: '92d71a7c1253f8f2430e2d308ecfef01c826b5c2',
|
|
100
|
+
},
|
|
101
|
+
// Bench row G3 (the NEGATIVE row) — the planted contract contradiction
|
|
102
|
+
// (docs/product/G3-SCOPING.md). A copy of the pulselog-u patient (patients are
|
|
103
|
+
// always copies) with one extra commit: two test/** files that pin
|
|
104
|
+
// assembleEmail's subject to two different strings, unsatisfiable together
|
|
105
|
+
// inside writeScope: src/**. Not frozen, not signed, no run — BUILT ON BRANCH
|
|
106
|
+
// per hamr's 2026-09-01 order; he reviews the diff before this row ever fires.
|
|
107
|
+
'pulselog-g3': {
|
|
108
|
+
spec: 'pulselog-g3-types.json',
|
|
109
|
+
workdir: '/home/hamr/PycharmProjects/bareloop-patients/pulselog-g3',
|
|
110
|
+
spine: 'pulselog-g3-bareloop',
|
|
111
|
+
seed: '54d18935dd310ec1986bdc2b37cebbc6999efc10',
|
|
112
|
+
},
|
|
113
|
+
// the first AUTHORED close (closeDecl, not a hand-written close script) — the
|
|
114
|
+
// sign-and-run e2e: same patient/seed as 'pulselog-types', close declared by the
|
|
115
|
+
// authoring interview (run msmsy579) instead of by hand.
|
|
116
|
+
'pulselog-author-types': {
|
|
117
|
+
spec: 'pulselog-author-types.json',
|
|
118
|
+
workdir: '/home/hamr/PycharmProjects/bareloop-patients/pulselog-author-live',
|
|
119
|
+
spine: 'pulselog-author-live-bareloop',
|
|
120
|
+
seed: '92d71a7c1253f8f2430e2d308ecfef01c826b5c2',
|
|
121
|
+
},
|
|
122
|
+
// the first spec authored through the PERSON path (run-interview.mjs →
|
|
123
|
+
// run-author.mjs, DeepSeek deepseek-flash, run mu2js0c0) rather than the
|
|
124
|
+
// AUTHOR path above — hash fc3d5a1b… (resolved-spec.json copied verbatim into
|
|
125
|
+
// jobs/, re-verified before and after the copy). workdir is the M2 source
|
|
126
|
+
// front door's own prepareSource copy (source-mu2bglzc/tree), not the patient's
|
|
127
|
+
// top level, so the spine dir lands nested one level deeper than its neighbours'
|
|
128
|
+
// — noted, not a defect in this row.
|
|
129
|
+
'pulselog-person-strict': {
|
|
130
|
+
spec: 'pulselog-person-strict-checks.json',
|
|
131
|
+
workdir: '/home/hamr/PycharmProjects/bareloop-patients/pulselog-person-live-2/out/source-mu2bglzc/tree',
|
|
132
|
+
spine: 'pulselog-person-live-2-bareloop',
|
|
133
|
+
seed: 'b57e692c000570e7586787f2d2e3b027a5fa6011',
|
|
134
|
+
},
|
|
135
|
+
'baremobile-types': {
|
|
136
|
+
spec: 'baremobile-u-types.json',
|
|
137
|
+
workdir: '/home/hamr/PycharmProjects/bareloop-patients/baremobile-u',
|
|
138
|
+
spine: 'baremobile-u-bareloop',
|
|
139
|
+
seed: 'd9b318fac78036bd3db35f68c4b1eb5ee634244d',
|
|
140
|
+
},
|
|
141
|
+
// Bench row G2 (the NEGATIVE row) — TESTGEN cold start in plan-v1. The legacy
|
|
142
|
+
// steps[] config went 0-of-43 (F38); this config's rate is UNKNOWN until its
|
|
143
|
+
// establishing run. Same patient/seed/grader as the l2accept battery
|
|
144
|
+
// (TESTGEN-PREREG); the spec is the cold goal. See docs/product/G2-SCOPING.md.
|
|
145
|
+
'aurora-testgen-cold': {
|
|
146
|
+
spec: 'aurora-testgen-cold.json',
|
|
147
|
+
workdir: '/home/hamr/PycharmProjects/bareloop-patients/aurora-soar',
|
|
148
|
+
spine: 'aurora-soar-bareloop',
|
|
149
|
+
seed: 'd661e507c5cd0981368d90ed3e3abf6e2bb9ed18',
|
|
150
|
+
},
|
|
151
|
+
'bareagent-types': {
|
|
152
|
+
spec: 'bareagent-u-types.json',
|
|
153
|
+
workdir: '/home/hamr/PycharmProjects/bareloop-patients/bareagent-u',
|
|
154
|
+
spine: 'bareagent-u-bareloop',
|
|
155
|
+
seed: '0037182a5a369d380e1635e0e4ab13e3557cfab9',
|
|
156
|
+
},
|
|
157
|
+
'bareguard-types': {
|
|
158
|
+
spec: 'bareguard-u-types.json',
|
|
159
|
+
workdir: '/home/hamr/PycharmProjects/bareloop-patients/bareguard-u',
|
|
160
|
+
spine: 'bareguard-u-bareloop',
|
|
161
|
+
seed: '2ae8fcd37041c186524a6eb5e953b9752cd602fa',
|
|
162
|
+
},
|
|
163
|
+
// PRD item 28 / 30.9 — the openai-api probe through THIS runner (the shipped
|
|
164
|
+
// one), not poc-run-param.mjs. Same job shape, same seed, its own patient copy.
|
|
165
|
+
'bareguard-types-deepseek': {
|
|
166
|
+
spec: 'bareguard-u-types-deepseek.json',
|
|
167
|
+
workdir: '/home/hamr/PycharmProjects/bareloop-patients/bareguard-u-deepseek',
|
|
168
|
+
spine: 'bareguard-u-deepseek-bareloop',
|
|
169
|
+
seed: '2ae8fcd37041c186524a6eb5e953b9752cd602fa',
|
|
170
|
+
},
|
|
171
|
+
// N4's hitl PROVING job (build plan §Proving; hamr's ruling, 2026-08-13). The job
|
|
172
|
+
// has been dark since 507adbb deleted the legacy `steps[]` path its old spec was
|
|
173
|
+
// written against, and it comes back as a plan-flow job with an AUTHORED close.
|
|
174
|
+
//
|
|
175
|
+
// `jobs/litectx-maintainer.json` DOES NOT EXIST, and this row does not create it:
|
|
176
|
+
// the spec is authored live through scripts/run-interview.mjs → run-author.mjs and
|
|
177
|
+
// signed by hamr, goal and answers included. Until it lands the runner refuses by
|
|
178
|
+
// name (see the spec read below) — a row is the runner's half of a job, never the
|
|
179
|
+
// signer's. The patient is a COPY at the convention this table already uses, and
|
|
180
|
+
// the seed is that copy's own HEAD (litectx v0.32.0, 115213d).
|
|
181
|
+
'litectx-maintainer': {
|
|
182
|
+
spec: 'litectx-maintainer.json',
|
|
183
|
+
workdir: '/home/hamr/PycharmProjects/bareloop-patients/litectx-maintainer',
|
|
184
|
+
spine: 'litectx-maintainer-bareloop',
|
|
185
|
+
seed: '115213dcb4c9f468c1045a212e6802456ed9119e',
|
|
186
|
+
},
|
|
187
|
+
};
|
|
188
|
+
|
|
189
|
+
// CLOSE_TIMEOUT_MS retired (PRD item 27/M3, 2026-09-07): a hardcoded 900s cap
|
|
190
|
+
// was a defaulted second ceiling with no operator-set floor, exactly the shape
|
|
191
|
+
// `maxWallMs`'s own no-default rule already forbids. The per-stage ceiling is
|
|
192
|
+
// now resolved once, below, before the watchdog is even spawned (F67 needs
|
|
193
|
+
// the real number to size its stale/grace windows) — `resolveCloseTimeoutMs`
|
|
194
|
+
// (src/closetimeout.js) is the ONE place this precedence is spelled out, and
|
|
195
|
+
// `src/planrun.js`'s own in-run resolution goes through the identical function.
|
|
196
|
+
// The close-fix loop's RETIRED iteration cap (PRD v1.46 §4). It no longer governs:
|
|
197
|
+
// that loop now stops on the same 2-strike no-progress rule the step ladder uses,
|
|
198
|
+
// read off the close's own per-stage numbers. This number survives as the bound for
|
|
199
|
+
// the one case that rule cannot read — a close whose output carries no number at all
|
|
200
|
+
// — because a governor that cannot see the variable must not be the governor.
|
|
201
|
+
const CAP_RUNS = 4;
|
|
202
|
+
// The step ladder's strike ceiling (src/ladder.js). Shell-owned, exactly where the
|
|
203
|
+
// step's fixed count used to be: a step ends when it stops making progress, not
|
|
204
|
+
// after N tries, and this is the number of no-progress iterations it is allowed.
|
|
205
|
+
const STRIKE_LIMIT = 2;
|
|
206
|
+
/** Thrown to unwind the engine to an exit code without ever calling
|
|
207
|
+
* `process.exit()` (constraint: a library function returns a code / throws,
|
|
208
|
+
* it never calls `process.exit` itself). Caught at the bottom of `execute`
|
|
209
|
+
* and of `main` — never expected to escape either. */
|
|
210
|
+
class ExitSignal extends Error {
|
|
211
|
+
/** @param {number} code */
|
|
212
|
+
constructor(code) {
|
|
213
|
+
super(`exit ${code}`);
|
|
214
|
+
this.code = code;
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* @typedef {object} Deps
|
|
220
|
+
* @property {Record<string,string|undefined>} [env]
|
|
221
|
+
* @property {(s: string) => void} [out]
|
|
222
|
+
* @property {(s: string) => void} [err]
|
|
223
|
+
* @property {any} [provider] the TEST SEAM: supplying this skips the real-key
|
|
224
|
+
* check entirely, the same shape `src/cli.js`'s `main(argv, deps)` uses.
|
|
225
|
+
* @property {(tier: string) => any} [providerFor]
|
|
226
|
+
* @property {any} [judgeProvider]
|
|
227
|
+
* @property {string} [runlistHome] F196 — a test seam for `appendRun`'s
|
|
228
|
+
* (src/runlist.js) `home` override, the same shape `deps.provider` already
|
|
229
|
+
* is. Production never sets it; `appendRun` falls through to its own
|
|
230
|
+
* `os.homedir()`-based default.
|
|
231
|
+
*/
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* @typedef {object} RunOpts
|
|
235
|
+
* @property {any} [spec] the parsed, not-yet-validated job spec — read by
|
|
236
|
+
* `resumeRun`/`answerDoor`, whose positional argument is the runid rather
|
|
237
|
+
* than the spec (unlike `startRun`, which takes `spec` positionally and
|
|
238
|
+
* never reads this).
|
|
239
|
+
* @property {string} [workdir] the patient's tree
|
|
240
|
+
* @property {string} [seed] the patient's frozen seed commit
|
|
241
|
+
* @property {string} [spineName] the spine directory name, beside `workdir`
|
|
242
|
+
* @property {string|null} [model] the raw `--model` tier name (`sonnet`/`haiku`), or null for the default
|
|
243
|
+
* @property {string|null} [readShim] the raw `--read-shim` arm name, or null for the default
|
|
244
|
+
* @property {string|null} [scout] the raw `--scout` value (`on`/`off`), or null for the default
|
|
245
|
+
* @property {string|null} [registry] a bridge registry directory
|
|
246
|
+
* @property {string|null} [workflow] the registry's workflow name (defaults to `spec.job`)
|
|
247
|
+
* @property {string|null} [approve] the spec hash being signed; omitted previews only
|
|
248
|
+
* @property {boolean} [reviewDoor] opt in to a green-class run opening a review door
|
|
249
|
+
* @property {string} [selector] how the re-invocation commands this prints should name the job (`--job x` / `--spec <path>`)
|
|
250
|
+
* @property {string} [specDesc] how a "go edit the spec" hint should read
|
|
251
|
+
* @property {Deps} [deps]
|
|
252
|
+
*/
|
|
253
|
+
|
|
254
|
+
/**
|
|
255
|
+
* @typedef {object} RunCtx
|
|
256
|
+
* @property {any} spec
|
|
257
|
+
* @property {string} specPath
|
|
258
|
+
* @property {string} selector
|
|
259
|
+
* @property {string} specDesc
|
|
260
|
+
* @property {string} workdir
|
|
261
|
+
* @property {string} seed
|
|
262
|
+
* @property {string} spineName
|
|
263
|
+
* @property {string|null} model
|
|
264
|
+
* @property {string|null} readShim
|
|
265
|
+
* @property {string|null} scout
|
|
266
|
+
* @property {string|null} registry
|
|
267
|
+
* @property {string|null} workflow
|
|
268
|
+
* @property {string|null} approve
|
|
269
|
+
* @property {boolean} reviewDoor
|
|
270
|
+
* @property {string|null} resume
|
|
271
|
+
* @property {string|null} door
|
|
272
|
+
* @property {string|null} decide
|
|
273
|
+
* @property {string|null} text
|
|
274
|
+
* @property {Deps} deps
|
|
275
|
+
*/
|
|
276
|
+
|
|
277
|
+
/**
|
|
278
|
+
* The one internal engine (PANEL-BUILD.md P0, hamr: "one library to prevent
|
|
279
|
+
* drift", then "agreed, one module three thin doors"). This is
|
|
280
|
+
* `scripts/run-u.mjs`'s own body, byte-for-byte in its ORCHESTRATION (the
|
|
281
|
+
* semantics it calls — `runJob`, `answerReviewDoor`, `readResume` — are
|
|
282
|
+
* already library and untouched): every `console.log`/`console.error`
|
|
283
|
+
* became `out`/`err`, and every `process.exit(n)` became `throw new
|
|
284
|
+
* ExitSignal(n)` (caught at the bottom of this function) so the library
|
|
285
|
+
* never calls `process.exit` itself — the same shape `src/cli.js`'s
|
|
286
|
+
* `main(argv, deps)` already uses.
|
|
287
|
+
*
|
|
288
|
+
* `startRun`/`resumeRun`/`answerDoor` (below) are the three thin doors: each
|
|
289
|
+
* builds this same shaped `ctx` and hands off to this one function — the
|
|
290
|
+
* engine is written once, and the doors differ only in how they arrive at
|
|
291
|
+
* `ctx.resume`/`ctx.door`/`ctx.decide`/`ctx.text`.
|
|
292
|
+
* @param {RunCtx} ctx
|
|
293
|
+
* @returns {Promise<number>} an exit code — never calls process.exit
|
|
294
|
+
*/
|
|
295
|
+
async function execute(ctx) {
|
|
296
|
+
const deps = ctx.deps ?? {};
|
|
297
|
+
const env = deps.env ?? process.env;
|
|
298
|
+
const out = deps.out ?? ((/** @type {string} */ s) => { console.log(s); });
|
|
299
|
+
const err = deps.err ?? ((/** @type {string} */ s) => { console.error(s); });
|
|
300
|
+
/** every operator/config stop this engine makes, in one exit code — thrown,
|
|
301
|
+
* never `process.exit()`'d, so this stays a library function (constraint:
|
|
302
|
+
* "never process.exit() inside the library — return an exit code").
|
|
303
|
+
* `@returns {never}` is load-bearing, not decorative: it is what lets tsc
|
|
304
|
+
* narrow every `let x; try { x = ... } catch { die(...) }` site below to a
|
|
305
|
+
* defined `x` afterward, honestly, because `die` truly never returns.
|
|
306
|
+
* @param {string} m
|
|
307
|
+
* @returns {never} */
|
|
308
|
+
const die = (m) => { err(m); throw new ExitSignal(2); };
|
|
309
|
+
let exitCode = 0;
|
|
310
|
+
try {
|
|
311
|
+
const spec = ctx.spec;
|
|
312
|
+
const specPath = ctx.specPath;
|
|
313
|
+
const SELECTOR = ctx.selector;
|
|
314
|
+
const SPEC_DESC = ctx.specDesc;
|
|
315
|
+
/** @type {{workdir: string, seed: string, spine: string}} */
|
|
316
|
+
const target = { workdir: ctx.workdir, seed: ctx.seed, spine: ctx.spineName };
|
|
317
|
+
|
|
318
|
+
// --model picks the DEFAULT worker tier (runner territory — the spec names no model, so
|
|
319
|
+
// the signed hash is unaffected). Tier names, not model ids. This map is the OPERATOR's
|
|
320
|
+
// and is deliberately WIDER than the planner's menu: since 2026-08-06 `STEP_MODELS` is
|
|
321
|
+
// sonnet-only (the haiku attribution probe — see src/plan.js), so a PLAN can no longer
|
|
322
|
+
// say haiku while `--model haiku` still can. haiku takes no output_config.effort
|
|
323
|
+
// (provider-gated, battery rule) — nothing to gate yet since neither tier sets effort.
|
|
324
|
+
// PRD v1.36 floor: the drafter/default tier is sonnet MINIMUM — measured, a haiku
|
|
325
|
+
// drafter died plan-red twice on the same rejection (ms7gne7s). `--model haiku` runs
|
|
326
|
+
// BELOW the floor: an explicit operator probe whose rows are probes, never battery
|
|
327
|
+
// evidence.
|
|
328
|
+
const DEFAULT_TIER_MODELS = { sonnet: 'claude-sonnet-5', haiku: 'claude-haiku-4-5-20251001' };
|
|
329
|
+
// `modelFlagArg` is null when --model was never passed at all — distinct from a
|
|
330
|
+
// flag that happens to resolve to the default tier's own id, because the SPEC
|
|
331
|
+
// (below, once loaded) needs to know whether a flag was actually given, not just
|
|
332
|
+
// what it resolved to (build-list #3: spec wins, a mismatched flag is refused).
|
|
333
|
+
const modelFlagArg = ctx.model ?? null;
|
|
334
|
+
const tierArg = modelFlagArg ?? 'sonnet';
|
|
335
|
+
const FLAG_TIER_MODEL = DEFAULT_TIER_MODELS[/** @type {keyof typeof DEFAULT_TIER_MODELS} */ (tierArg)];
|
|
336
|
+
if (!FLAG_TIER_MODEL) { err(`unknown --model "${tierArg}" — one of: ${Object.keys(DEFAULT_TIER_MODELS).join(', ')}`); throw new ExitSignal(2); }
|
|
337
|
+
// --read-shim picks the READ SHIM's ARM (src/readshim.js, Phase 2 A0/A1/A2/A3). Runner
|
|
338
|
+
// territory exactly like --model: the spec names no shim, so the signed hash is
|
|
339
|
+
// unaffected. Phase 2 itself never flipped this default — its own "what this
|
|
340
|
+
// experiment does NOT claim" — leaving the flip as a decision only hamr could make.
|
|
341
|
+
// hamr made it explicitly on 2026-08-31: default becomes A1 cap for everyday runs;
|
|
342
|
+
// `--read-shim off` restores A0 (bench rows pin `off` explicitly, handled separately).
|
|
343
|
+
// This is the operator's flip, not the experiment's — the Phase 2 no-self-flip promise
|
|
344
|
+
// was honored.
|
|
345
|
+
//
|
|
346
|
+
// The arm NAMES are the operator's spelling; `readShimArm` is the one resolver, and it
|
|
347
|
+
// THROWS on anything it does not recognise (BA-4 param-guard class) before a token is
|
|
348
|
+
// spent — a row that silently ran the wrong arm is worse than a row that refused.
|
|
349
|
+
const READ_SHIM_ARM_NAMES = /** @type {Record<string, boolean|'cap'|'diff'>} */ ({
|
|
350
|
+
A0: false, off: false, false: false,
|
|
351
|
+
A1: 'cap', cap: 'cap',
|
|
352
|
+
A2: 'diff', diff: 'diff',
|
|
353
|
+
A3: true, all: true, true: true,
|
|
354
|
+
});
|
|
355
|
+
const shimArg = ctx.readShim ?? null;
|
|
356
|
+
if (shimArg !== null && !Object.prototype.hasOwnProperty.call(READ_SHIM_ARM_NAMES, shimArg)) {
|
|
357
|
+
err(`unknown --read-shim "${shimArg}" — one of: ${Object.keys(READ_SHIM_ARM_NAMES).join(', ')}`);
|
|
358
|
+
throw new ExitSignal(2);
|
|
359
|
+
}
|
|
360
|
+
const READ_SHIM = shimArg === null ? 'cap' : READ_SHIM_ARM_NAMES[shimArg];
|
|
361
|
+
// resolve it HERE, at argv, not at runJob: the throw must land before the approval
|
|
362
|
+
// gate, the patient reset and the provider, not three hundred lines and one API key later.
|
|
363
|
+
const READ_SHIM_LEVERS = readShimArm(READ_SHIM);
|
|
364
|
+
const READ_SHIM_LABEL = shimArg === null
|
|
365
|
+
? 'read shim CAP (A1 — default per hamr 2026-08-31; pass --read-shim off for A0, as bench rows do)'
|
|
366
|
+
: `read shim ${shimArg} -> ${JSON.stringify(READ_SHIM)} (cap ${READ_SHIM_LEVERS.cap} · pointer ${READ_SHIM_LEVERS.pointer} · diff ${READ_SHIM_LEVERS.diff} · G1 ${READ_SHIM_LEVERS.g1})`;
|
|
367
|
+
/** every re-invocation this script PRINTS carries the arm. A resume that drops it runs
|
|
368
|
+
* the default (A1 cap since 2026-08-31) while being filed under the arm it was launched
|
|
369
|
+
* with — a silently mislabelled row
|
|
370
|
+
* is the one failure the arm resolver's throw cannot catch. */
|
|
371
|
+
const SHIM_TAIL = shimArg === null ? '' : ` --read-shim ${shimArg}`;
|
|
372
|
+
|
|
373
|
+
// --scout picks whether the plan flow pays for the read-only survey at all
|
|
374
|
+
// (docs/product/SCOUT-CONTRAST.md). Runner territory exactly like --read-shim
|
|
375
|
+
// and --model: the spec names no scout, so the signed hash is unaffected. An
|
|
376
|
+
// unrecognised value throws at argv, before the approval gate — the same
|
|
377
|
+
// param-guard class as --read-shim above, for the same reason: a silently
|
|
378
|
+
// coerced knob would mislabel a bench row.
|
|
379
|
+
const SCOUT_NAMES = /** @type {Record<string, boolean>} */ ({ on: true, true: true, off: false, false: false });
|
|
380
|
+
const scoutArg = ctx.scout ?? null;
|
|
381
|
+
if (scoutArg !== null && !Object.prototype.hasOwnProperty.call(SCOUT_NAMES, scoutArg)) {
|
|
382
|
+
err(`unknown --scout "${scoutArg}" — one of: ${Object.keys(SCOUT_NAMES).join(', ')}`);
|
|
383
|
+
throw new ExitSignal(2);
|
|
384
|
+
}
|
|
385
|
+
const SCOUT = scoutArg === null ? true : SCOUT_NAMES[scoutArg];
|
|
386
|
+
// item 34 L2: three real states, three labels — an explicit `--scout on` used
|
|
387
|
+
// to print the exact same "(default)" text as no flag at all, so a bench-log
|
|
388
|
+
// auditor reading stdout could never tell "operator explicitly chose on" from
|
|
389
|
+
// "no flag given" for that row (fix-ledger, 2026-09-05 @ c9f100d).
|
|
390
|
+
const SCOUT_LABEL = scoutArg === null
|
|
391
|
+
? 'scout ON (default)'
|
|
392
|
+
: (SCOUT ? 'scout ON (--scout on — operator explicit)' : 'scout OFF (--scout off — operator probe; planner drafts blind)');
|
|
393
|
+
/** every re-invocation this script PRINTS carries the arm — the SHIM_TAIL rule,
|
|
394
|
+
* so a resume never silently drops it and runs the default under the arm's label. */
|
|
395
|
+
const SCOUT_TAIL = scoutArg !== null && !SCOUT ? ' --scout off' : '';
|
|
396
|
+
|
|
397
|
+
const WORKDIR = target.workdir;
|
|
398
|
+
const SEED = target.seed;
|
|
399
|
+
const specHash = jobSpecHash(spec);
|
|
400
|
+
// MODEL (build-list #3, hamr's GO 2026-08-30): the signed spec's `model`, if
|
|
401
|
+
// present, wins outright; a --model flag naming a DIFFERENT id is refused
|
|
402
|
+
// (never silently overridden) — resolveWorkerModel is the one decision, pure
|
|
403
|
+
// and tested apart from this script. The JUDGE's model is resolved separately
|
|
404
|
+
// (`resolveJudge`, below the key block): it defaults to this same resolved
|
|
405
|
+
// worker model and a signed `judge` override wins over it.
|
|
406
|
+
// The provider's OWN tier table (src/providers.js) supplies the default —
|
|
407
|
+
// not the anthropic-only DEFAULT_TIER_MODELS above (that map is the
|
|
408
|
+
// `--model` FLAG's vocabulary only, sonnet/haiku, unrelated to which
|
|
409
|
+
// provider the spec names). `spec.provider` is not validateJob'd until
|
|
410
|
+
// runJob below, so an unrecognized value dies here with the same operator-
|
|
411
|
+
// facing message resolveWorkerModel's own errors use, rather than an
|
|
412
|
+
// unhandled throw.
|
|
413
|
+
let providerEntry;
|
|
414
|
+
try {
|
|
415
|
+
providerEntry = resolveProvider(spec.provider);
|
|
416
|
+
} catch (e) {
|
|
417
|
+
die(/** @type {Error} */ (e).message);
|
|
418
|
+
}
|
|
419
|
+
// PRD item 31.3 — an ADMITTED-PENDING-PROBE provider says so, LOUDLY, on every
|
|
420
|
+
// launch. The probe rule (item 28 ruling (d)) is that no endpoint enters the
|
|
421
|
+
// menu without its own clean paid probe; hamr admitted `gemini-api` to the menu
|
|
422
|
+
// before that fire, so the honest state is "runnable, unproven" and it must be
|
|
423
|
+
// visible at the moment money is about to be spent — not buried in a doc. This
|
|
424
|
+
// NEVER refuses: refusing would be a second ruling nobody made.
|
|
425
|
+
for (const line of probeWarningLines(/** @type {string} */ (spec.provider)) ?? []) err(line);
|
|
426
|
+
|
|
427
|
+
let modelResolution;
|
|
428
|
+
try {
|
|
429
|
+
modelResolution = resolveWorkerModel({
|
|
430
|
+
specModel: spec.model,
|
|
431
|
+
flagModel: modelFlagArg === null ? undefined : FLAG_TIER_MODEL,
|
|
432
|
+
defaultModel: /** @type {NonNullable<typeof providerEntry>} */ (providerEntry).tiers.sonnet,
|
|
433
|
+
});
|
|
434
|
+
} catch (e) {
|
|
435
|
+
die(/** @type {Error} */ (e).message);
|
|
436
|
+
}
|
|
437
|
+
const MODEL = modelResolution.model;
|
|
438
|
+
const MODEL_LABEL = modelResolution.source === 'spec' ? `${MODEL} (spec)` : MODEL;
|
|
439
|
+
// `maxWallMs` is OPTIONAL in job-v1 and has NO DEFAULT — a spec without one is
|
|
440
|
+
// time-unbounded by explicit operator choice, and the whole point of the missing
|
|
441
|
+
// default is that an unbounded run must be a VISIBLE choice rather than a silent
|
|
442
|
+
// second ceiling (src/clock.js, F45). So the runner does two things and neither is
|
|
443
|
+
// "invent a number": it NAMES the unbounded state in every readout instead of
|
|
444
|
+
// printing `NaN min` (`undefined / 60000`), and it OMITS `--wall-ms` rather than
|
|
445
|
+
// handing the watchdog the literal string "undefined" — u-watchdog's num() would
|
|
446
|
+
// fall back to its null default and disarm the deadline trigger silently while the
|
|
447
|
+
// header still claimed a wall. That is the same NaN-disarms-a-guard class as the
|
|
448
|
+
// closeStages fix below, one flag over.
|
|
449
|
+
const WALL_MS = typeof spec.maxWallMs === 'number' && Number.isFinite(spec.maxWallMs) ? spec.maxWallMs : null;
|
|
450
|
+
const WALL_LABEL = WALL_MS === null ? 'UNBOUNDED (spec sets no maxWallMs — deliberate operator choice; no outside deadline)' : `${WALL_MS / 60000}min`;
|
|
451
|
+
|
|
452
|
+
// ── --resume: continue a run its own MONEY (or TIME) cap — a STALL, or a transport
|
|
453
|
+
// casualty that outlived its one retry — stopped ──
|
|
454
|
+
// PRD v1.46 §3, and it closes the third leg of hamr's resume rulings: kill-resume
|
|
455
|
+
// existed on the reuse path and W-2 pauses the wall decision-ready, but the case the
|
|
456
|
+
// original ruling was actually about — "why would i want to waste more money on
|
|
457
|
+
// something i already started" — had no machinery on the U path at all. This runner
|
|
458
|
+
// hard-reset the patient on every launch and the resume reader classed any landed
|
|
459
|
+
// job-end as complete.
|
|
460
|
+
//
|
|
461
|
+
// The TOP-UP ITSELF IS NOT HERE and never will be: raising `budgetUsd` is a spec edit,
|
|
462
|
+
// so it changes the hash and hamr signs the new one with `--approve`. This flag only
|
|
463
|
+
// declines to throw away what the dead run already bought — the tree, the plan, the
|
|
464
|
+
// steps that finished, and the money, which is folded IN so the ceiling cannot widen.
|
|
465
|
+
const RESUME = ctx.resume ?? null;
|
|
466
|
+
/** the terminals that are a checkpoint rather than a verdict — THE LIBRARY's set
|
|
467
|
+
* (`CHECKPOINT_OUTCOMES`, src/reuse.js), not this script's own.
|
|
468
|
+
*
|
|
469
|
+
* It used to be a literal here, and N4 is what made that untenable: a fourth
|
|
470
|
+
* checkpoint (`hitl-pause`) had to be added in two places at once, and the
|
|
471
|
+
* exported bundle (PRD v1.44 §2 — a thin runner with bareloop as a dependency)
|
|
472
|
+
* would have needed a third copy. `readResume` still takes the list as a
|
|
473
|
+
* PARAMETER and still defaults to empty — that default is what the reuse loop's
|
|
474
|
+
* graded-row semantics depend on — so what moved is the canonical ANSWER, not
|
|
475
|
+
* the seam. Same reasoning that put the pause TTL in the library (OPEN-2, hamr,
|
|
476
|
+
* 2026-08-13).
|
|
477
|
+
*
|
|
478
|
+
* `provider-red` folded INTO the canonical constant 2026-08-25 (PRD v1.80 TODO
|
|
479
|
+
* #4, F115) — it lived here as a local widening for one pass because `src/reuse.js`
|
|
480
|
+
* sat outside that change's file scope; the reasoning for why it belongs on this
|
|
481
|
+
* list at all now lives beside the constant itself, not here, so there is exactly
|
|
482
|
+
* one place that reasoning can drift.
|
|
483
|
+
*
|
|
484
|
+
* Nothing else is resumable — a green is done, and a red is an answer. */
|
|
485
|
+
const RESUMABLE_HALTS = CHECKPOINT_OUTCOMES;
|
|
486
|
+
|
|
487
|
+
// ── THE THREE DOORS (N4, 2026-08-12 §1) — the hitl surface, at the terminal ──
|
|
488
|
+
//
|
|
489
|
+
// There is no interactive surface anywhere in this repo and there is not going to
|
|
490
|
+
// be one here: paid runs launch setsid-detached under `systemd-inhibit`, so a
|
|
491
|
+
// process blocking on stdin would block on a terminal nobody is watching. "Three
|
|
492
|
+
// buttons" is therefore a PAUSE plus a re-invocation — the run stops
|
|
493
|
+
// decision-ready, the person reads the evidence package, and their answer comes
|
|
494
|
+
// back as a flag on the resume that continues it.
|
|
495
|
+
//
|
|
496
|
+
// SIGNER-ONLY (ruling 4): the only signer proof this repo has is the spec-hash
|
|
497
|
+
// approval, so the decision rides that same gate rather than inventing a second
|
|
498
|
+
// identity mechanism. A decision without the matching hash never reaches `runJob`.
|
|
499
|
+
//
|
|
500
|
+
// The SEMANTICS are the library's (`normalizeHumanRuling`, src/kinds.js): three
|
|
501
|
+
// doors and no fourth, and a `rerun` whose text is the whole of what the fix
|
|
502
|
+
// worker will be given. This script asks that function and prints its refusal —
|
|
503
|
+
// a second rulebook here is how a runner comes to admit what the run refuses.
|
|
504
|
+
const DECIDE = ctx.decide ?? null;
|
|
505
|
+
const TEXT = ctx.text ?? null;
|
|
506
|
+
|
|
507
|
+
// ── THE REVIEW DOOR (softgreen module 8, PRD v1.71 §3) — the same three doors,
|
|
508
|
+
// one level out: at the END of a run rather than at a stage inside the close.
|
|
509
|
+
//
|
|
510
|
+
// `--door <runid>` answers a FINISHED run's door; `--resume` continues a HALTED
|
|
511
|
+
// one. They are different runs in different states and are refused together
|
|
512
|
+
// rather than merged, because a merge would have to pick which question the
|
|
513
|
+
// operator meant.
|
|
514
|
+
//
|
|
515
|
+
// `--review-door` is the OPT-IN half, and it is the one operator-facing default
|
|
516
|
+
// this module has: a soft-green run opens its door unasked (its credit is held
|
|
517
|
+
// until a person accepts, and nothing else releases it), and a green-class run
|
|
518
|
+
// behaves exactly as it always has unless this flag is passed. PRD v1.71 §3 says
|
|
519
|
+
// "the end of EVERY run" and also that a green door is NON-BLOCKING; the reading
|
|
520
|
+
// that changes an existing job's spine without being asked is hamr's to pick, and
|
|
521
|
+
// flipping it is one line in `REVIEW_DOOR_CLASSES` (src/kinds.js).
|
|
522
|
+
const DOOR = ctx.door ?? null;
|
|
523
|
+
const REVIEW_DOOR_ON = ctx.reviewDoor === true;
|
|
524
|
+
if (DOOR !== null && RESUME !== null) {
|
|
525
|
+
die('--door answers the review door of a run that FINISHED; --resume continues one that HALTED. Those are two '
|
|
526
|
+
+ 'different runs in two different states — give one.');
|
|
527
|
+
}
|
|
528
|
+
if (DECIDE !== null && RESUME === null && DOOR === null) {
|
|
529
|
+
die('--decide answers a PAUSED run (--resume) or a finished run\'s REVIEW DOOR (--door), and there is neither here. '
|
|
530
|
+
+ 'A decision is an answer to something that already exists — there is nothing for it to rule on at the start of a fresh run.');
|
|
531
|
+
}
|
|
532
|
+
// A door that carries no text REFUSES the flag rather than dropping it. Silently
|
|
533
|
+
// discarding words a person typed is worse than refusing them: they would believe
|
|
534
|
+
// they had said something, and nothing downstream would ever carry it.
|
|
535
|
+
if (TEXT !== null && DECIDE !== 'rerun') {
|
|
536
|
+
die(`--text is for the rerun door: only a "rerun" carries text (the human's words ARE the gap the worker converts from). `
|
|
537
|
+
+ `${DECIDE === null ? 'No --decide was given at all.' : `This run was handed --decide ${DECIDE}, which takes none.`}`);
|
|
538
|
+
}
|
|
539
|
+
/** the signer's answer, normalised at the LIBRARY's seam, or null for every
|
|
540
|
+
* ordinary run and for the leg that pauses. Never authored here, never defaulted,
|
|
541
|
+
* never inferred. */
|
|
542
|
+
const RULING = (() => {
|
|
543
|
+
if (DECIDE === null) return null;
|
|
544
|
+
const norm = normalizeHumanRuling({ decision: DECIDE, ...(TEXT === null ? {} : { text: TEXT }) });
|
|
545
|
+
if (!norm.ok) die(`--decide ${JSON.stringify(DECIDE)} is not a ruling this run can act on: ${norm.why}`);
|
|
546
|
+
return norm.ruling;
|
|
547
|
+
})();
|
|
548
|
+
/** the patient's tree, and the spine directory beside it — derived ONCE, here, because
|
|
549
|
+
* both the RESUME reader below and the live run further down need them. Two spellings of
|
|
550
|
+
* one path is how `--resume` comes to read a different directory than the run writes. */
|
|
551
|
+
const wd = resolve(WORKDIR);
|
|
552
|
+
const spineDir = join(wd, '..', target.spine);
|
|
553
|
+
/** `--resume` takes the runid (the conventional `<spine dir>/u-<runid>.jsonl`) or an
|
|
554
|
+
* explicit path — the path form is what makes these gates testable without touching
|
|
555
|
+
* an operator's real spine directory. */
|
|
556
|
+
const deadSpineFile = RESUME == null ? null
|
|
557
|
+
: (RESUME.includes('/') || RESUME.endsWith('.jsonl') ? resolve(RESUME) : join(spineDir, `u-${RESUME}.jsonl`));
|
|
558
|
+
/** @type {any} */
|
|
559
|
+
let dead = null;
|
|
560
|
+
/** F102 — the decision this checkpoint HOLDS: an answer the person already gave, on
|
|
561
|
+
* a leg that stopped before a single round was bought for it. Null on every ordinary
|
|
562
|
+
* resume. It is applied directly and the ask is never re-rendered — the whole of the
|
|
563
|
+
* finding is that the person paid for that decision once and must not pay again. */
|
|
564
|
+
let HELD = null;
|
|
565
|
+
/** the paused run's own `hitl-pause` record — the evidence package the LIBRARY
|
|
566
|
+
* assembled (the ask, every mechanical stage's result, the changed set). Hoisted
|
|
567
|
+
* out of the reader block because the preview below renders it, and re-deriving
|
|
568
|
+
* the run's facts at the surface is how a screen comes to disagree with a spine. */
|
|
569
|
+
let pauseRecord = null;
|
|
570
|
+
if (deadSpineFile !== null) {
|
|
571
|
+
if (!existsSync(deadSpineFile)) die(`--resume: no spine at ${deadSpineFile} — a resume continues a run that happened, and this one left no log`);
|
|
572
|
+
let raw = '';
|
|
573
|
+
try { raw = readFileSync(deadSpineFile, 'utf8'); } catch (e) { die(`--resume: cannot read ${deadSpineFile}: ${e.message}`); }
|
|
574
|
+
/** @type {any[]} */
|
|
575
|
+
const deadEvents = [];
|
|
576
|
+
const lines = raw.split('\n');
|
|
577
|
+
lines.forEach((line, i) => {
|
|
578
|
+
if (!line.trim()) return;
|
|
579
|
+
try { deadEvents.push(JSON.parse(line)); } catch (e) {
|
|
580
|
+
// a kill can land mid-append, so a broken LAST line is the failure being modelled
|
|
581
|
+
// and is tolerated (named, never silently dropped). A broken line anywhere else is
|
|
582
|
+
// a corrupt log, and reconstructing from one would invent a history.
|
|
583
|
+
if (i >= lines.length - 2) err(`--resume: ignoring a truncated final line in ${deadSpineFile} (a kill mid-append — the expected shape)`);
|
|
584
|
+
else die(`--resume: ${deadSpineFile} is corrupt at line ${i + 1} (${e.message}) — a reconstruction from a damaged log would invent a history`);
|
|
585
|
+
}
|
|
586
|
+
});
|
|
587
|
+
// is the dead run actually dead? Two processes on one patient is unrecoverable; a
|
|
588
|
+
// false refusal costs one sentence. run-u's own pid is not on its spine, so the
|
|
589
|
+
// watchdog's report is the pid there is — and its absence is not evidence of life.
|
|
590
|
+
const wdFile = `${deadSpineFile}.watchdog.json`;
|
|
591
|
+
/** @type {any} */
|
|
592
|
+
let watchdog = null;
|
|
593
|
+
if (existsSync(wdFile)) { try { watchdog = JSON.parse(readFileSync(wdFile, 'utf8')); } catch { /* an unreadable report is not evidence of life */ } }
|
|
594
|
+
if (Number.isInteger(watchdog?.pid)) {
|
|
595
|
+
let alive = true;
|
|
596
|
+
try { process.kill(watchdog.pid, 0); } catch { alive = false; }
|
|
597
|
+
if (alive) {
|
|
598
|
+
let cmdline = null;
|
|
599
|
+
try { cmdline = readFileSync(`/proc/${watchdog.pid}/cmdline`, 'utf8').replace(/\0/g, ' ').trim(); } catch { /* no /proc, or not ours */ }
|
|
600
|
+
if (cmdline === null || cmdline.includes('run-u.mjs') || cmdline.includes('u-watchdog.mjs')) {
|
|
601
|
+
die(`--resume: pid ${watchdog.pid} from ${deadSpineFile} is still alive${cmdline ? ` (${cmdline.slice(0, 120)})` : ' and this process cannot read its command line'}. Two processes on one patient is unrecoverable — stop it first.`);
|
|
602
|
+
}
|
|
603
|
+
err(`--resume: pid ${watchdog.pid} is alive but is NOT this runner (${cmdline.slice(0, 80)}) — the pid was recycled; continuing.`);
|
|
604
|
+
}
|
|
605
|
+
}
|
|
606
|
+
// WHEN did the dead leg stop? The watchdog's kill record is later, better evidence
|
|
607
|
+
// than the last spine event for a run that was KILLED — and worse evidence for one
|
|
608
|
+
// that ended itself, which is what N4's pause is. The preference order lives in
|
|
609
|
+
// `deathAtOf` (src/u-readout.js) so it is reachable by a test; the hazard it
|
|
610
|
+
// closes was measured, not imagined (a report dated after a pause bills the human's
|
|
611
|
+
// deciding time to the run's wall and can zero the remainder outright).
|
|
612
|
+
const deathAt = deathAtOf({ watchdogAt: watchdog?.at, events: deadEvents });
|
|
613
|
+
dead = readResume(deadEvents, {
|
|
614
|
+
direct: true,
|
|
615
|
+
resumableOutcomes: RESUMABLE_HALTS,
|
|
616
|
+
...(deathAt === null ? {} : { deathAt }),
|
|
617
|
+
});
|
|
618
|
+
if (!dead.started) die(`--resume: ${deadSpineFile} carries no job-start — that is not a bareloop run's spine`);
|
|
619
|
+
if (dead.job !== spec.job) die(`--resume: that spine is job "${dead.job}", not "${spec.job}" — a resume continues ONE job, and running another job's plan as this one's is a substitution nothing here is allowed to make`);
|
|
620
|
+
if (dead.greened) die('--resume: that run already GREENED — there is nothing to resume.');
|
|
621
|
+
if (dead.ended) die(`--resume: that run reached its own terminal (${dead.endOutcome}) — only a governance halt (${RESUMABLE_HALTS.join(' / ')}) leaves work to continue. Start a fresh run.`);
|
|
622
|
+
if (!dead.restart) die(`--resume: ${deadSpineFile} has no attempt to continue — it never opened one.`);
|
|
623
|
+
// ── THE PAUSE TTL (2026-08-12 §2): a hitl checkpoint is kept for 60 days.
|
|
624
|
+
//
|
|
625
|
+
// The rule and the number are the LIBRARY's (`checkpointAgeGate` / `PAUSE_TTL_MS`,
|
|
626
|
+
// OPEN-2 as hamr ruled it) so the exported bundle inherits them; this reads its
|
|
627
|
+
// answer and refuses. It sits HERE, with the spine gates, rather than beside
|
|
628
|
+
// `resumeTreeGate` further down, for two reasons: an expired checkpoint must refuse
|
|
629
|
+
// BEFORE the operator signs a hash for it, and the age gate needs no git — the
|
|
630
|
+
// tree gate does, and that is why the tree gate is where it is.
|
|
631
|
+
//
|
|
632
|
+
// `applies:false` on every other terminal is the gate's own doing: ageing out a
|
|
633
|
+
// cap-halt would be a governance change nobody ruled. An UNREADABLE stamp refuses
|
|
634
|
+
// too (unknown is not young — F6), which is why this branches on `ok` and not on
|
|
635
|
+
// an age comparison of its own.
|
|
636
|
+
const age = checkpointAgeGate(deadEvents);
|
|
637
|
+
if (!age.ok) {
|
|
638
|
+
err(`CHECKPOINT EXPIRED — ${age.detail}`);
|
|
639
|
+
err('The work is still on the run\'s own branch; what has expired is the DECISION, not the tree. The levers are yours:');
|
|
640
|
+
err(' - start a fresh run against the current tree (the same hash, nothing to re-sign);');
|
|
641
|
+
err(` - or revise the goal/spec in ${SPEC_DESC} — a spec edit, so the hash changes and you sign the new one;`);
|
|
642
|
+
err(' - or abandon it and keep the verdict the paused run already minted.');
|
|
643
|
+
throw new ExitSignal(2);
|
|
644
|
+
}
|
|
645
|
+
// F102 — DOES THIS CHECKPOINT ALREADY HOLD AN ANSWER? A leg that received a
|
|
646
|
+
// decision and stopped before a single round was bought for it left the person's
|
|
647
|
+
// words on its own spine, and the reader carries them. Read BEFORE the two gates
|
|
648
|
+
// below, because a held decision is precisely the case where the terminal is NOT a
|
|
649
|
+
// pause and a decision is nonetheless outstanding — the state F102's resume could
|
|
650
|
+
// not tell apart from "nobody has looked yet".
|
|
651
|
+
HELD = dead.restart.pendingDecision ?? null;
|
|
652
|
+
// and TWO ANSWERS TO ONE QUESTION is refused by the library's own resolver, so the
|
|
653
|
+
// runner cannot admit what the run refuses (a second rulebook here is how those two
|
|
654
|
+
// come apart). The message names the held decision: the operator has to know what
|
|
655
|
+
// they would be overriding. An unreadable HELD record refuses through the same seam
|
|
656
|
+
// rather than being repaired — a decision nobody can read is not one to act on.
|
|
657
|
+
const resolved = resolveHumanRuling(RULING, HELD);
|
|
658
|
+
if (!resolved.ok) {
|
|
659
|
+
die(`${RULING === null ? `--resume: the held decision on ${deadSpineFile}` : `--decide ${RULING.decision}`}: ${resolved.why}`);
|
|
660
|
+
}
|
|
661
|
+
// A decision answers a PAUSE. Every other checkpoint here stopped on an allowance,
|
|
662
|
+
// and nothing about it is waiting on a person — handing one a ruling would let an
|
|
663
|
+
// `accept` mint a green over evidence nobody was ever shown. The library refuses a
|
|
664
|
+
// ruling for a close with no human stage; this is the other half of the same
|
|
665
|
+
// question, and only the runner can ask it (it is about WHICH checkpoint is being
|
|
666
|
+
// answered, not about the close's shape). A HELD decision is deliberately exempt:
|
|
667
|
+
// it was answered against a pause, on a leg that then died carrying it.
|
|
668
|
+
if (RULING !== null && dead.endOutcome !== HITL_PAUSE) {
|
|
669
|
+
die(`--decide ${RULING.decision}: that run stopped on ${dead.endOutcome ?? 'an unrecorded terminal'}, not at a human stage. `
|
|
670
|
+
+ 'A decision answers a pause — the run has to have asked you something before you can answer it. Resume it without a decision.');
|
|
671
|
+
}
|
|
672
|
+
pauseRecord = deadEvents.filter((/** @type {any} */ e) => e?.type === HITL_PAUSE).at(-1) ?? null;
|
|
673
|
+
}
|
|
674
|
+
// ── THE DOOR's own reader. A finished run's spine, its door record, and what it
|
|
675
|
+
// spent — the last of those because the signed budget is the CHAIN's ceiling and a
|
|
676
|
+
// rerun spends what REMAINS of it (F103's money half: time is fresh, money folds).
|
|
677
|
+
/** @type {string|null} */
|
|
678
|
+
const doorSpineFile = DOOR == null ? null
|
|
679
|
+
: (DOOR.includes('/') || DOOR.endsWith('.jsonl') ? resolve(DOOR) : join(spineDir, `u-${DOOR}.jsonl`));
|
|
680
|
+
/** @type {any[]|null} */
|
|
681
|
+
let doorEvents = null;
|
|
682
|
+
/** @type {any} */
|
|
683
|
+
let doorRecord = null;
|
|
684
|
+
/** @type {{spentUsd: number, spendComplete: boolean}|null} */
|
|
685
|
+
let doorPrior = null;
|
|
686
|
+
if (doorSpineFile !== null) {
|
|
687
|
+
if (!existsSync(doorSpineFile)) die(`--door: no spine at ${doorSpineFile} — a door belongs to a run that happened, and this one left no log`);
|
|
688
|
+
let raw = '';
|
|
689
|
+
try { raw = readFileSync(doorSpineFile, 'utf8'); } catch (e) { die(`--door: cannot read ${doorSpineFile}: ${e.message}`); }
|
|
690
|
+
doorEvents = [];
|
|
691
|
+
raw.split('\n').forEach((line, i) => {
|
|
692
|
+
if (!line.trim()) return;
|
|
693
|
+
try { /** @type {any[]} */ (doorEvents).push(JSON.parse(line)); } catch (e) {
|
|
694
|
+
die(`--door: ${doorSpineFile} is corrupt at line ${i + 1} (${e.message}) — a decision made on a damaged record is a decision about a run nobody can read`);
|
|
695
|
+
}
|
|
696
|
+
});
|
|
697
|
+
const start = doorEvents.find((/** @type {any} */ e) => e?.type === 'job-start');
|
|
698
|
+
if (!start) die(`--door: ${doorSpineFile} carries no job-start — that is not a bareloop run's spine`);
|
|
699
|
+
if (start.job !== spec.job) die(`--door: that spine is job "${start.job}", not "${spec.job}" — a door is answered against the spec that was signed for it`);
|
|
700
|
+
// THE DOOR ITSELF. Its absence is a refusal and never an invented one: a run that
|
|
701
|
+
// opened no door has a verdict standing on its own, and answering a door nobody
|
|
702
|
+
// was shown the evidence for is the rubber stamp this whole class exists to stop.
|
|
703
|
+
doorRecord = doorRecordOf(doorEvents);
|
|
704
|
+
if (!doorRecord) {
|
|
705
|
+
die(`--door ${DOOR}: that run opened no review door — its verdict (${doorEvents.findLast((/** @type {any} */ e) => e?.type === 'job-end')?.outcome ?? 'unrecorded'}) stands on its own and there is nothing here to dispose of.\n`
|
|
706
|
+
+ ' A soft-green run opens one always; a green-class run opens one only when the run was launched with --review-door.');
|
|
707
|
+
}
|
|
708
|
+
// …and the 60-day TTL, read HERE for the same reason the pause TTL is read with
|
|
709
|
+
// the spine gates: an expired door must refuse BEFORE a hash is signed for it.
|
|
710
|
+
const dage = doorAgeGate(doorRecord);
|
|
711
|
+
if (!dage.ok) {
|
|
712
|
+
err(`DOOR EXPIRED — ${dage.detail}`);
|
|
713
|
+
err('That expiry IS what "cancel" used to be: the verdict the run minted stands, nothing graduated, and nobody had to decide.');
|
|
714
|
+
err(' - start a fresh run against the current tree (the same hash, nothing to re-sign);');
|
|
715
|
+
err(` - or revise the goal/spec in ${SPEC_DESC} — a spec edit, so the hash changes and you sign the new one.`);
|
|
716
|
+
throw new ExitSignal(2);
|
|
717
|
+
}
|
|
718
|
+
const je = doorEvents.findLast((/** @type {any} */ e) => e?.type === 'job-end');
|
|
719
|
+
const spentKnown = typeof je?.spentUsd === 'number' && Number.isFinite(je.spentUsd);
|
|
720
|
+
doorPrior = {
|
|
721
|
+
spentUsd: spentKnown ? je.spentUsd : 0,
|
|
722
|
+
// a FLOOR stays a floor across the door (F6): an absent FIGURE is never "complete
|
|
723
|
+
// at $0" — completeness is only trusted when the number it completes is known
|
|
724
|
+
spendComplete: spentKnown && je?.spendComplete !== false,
|
|
725
|
+
};
|
|
726
|
+
}
|
|
727
|
+
|
|
728
|
+
/** is this resume the one a PERSON has to answer? Read off the recorded terminal,
|
|
729
|
+
* never off the presence of a `--decide` — the whole point is that the flag is
|
|
730
|
+
* absent on the first visit. */
|
|
731
|
+
const PAUSED = !!dead && dead.endOutcome === HITL_PAUSE;
|
|
732
|
+
|
|
733
|
+
/**
|
|
734
|
+
* The LINE-level half of the evidence package (ruling 2). The library carries WHICH
|
|
735
|
+
* files moved — the granularity it can state honestly from where it sits — and this
|
|
736
|
+
* reads the lines themselves out of the patient.
|
|
737
|
+
*
|
|
738
|
+
* READ-ONLY and BEST-EFFORT, in that order. It runs in the preview, above the
|
|
739
|
+
* approval gate, so it may never mutate a patient (no `add -N`, no index touch) and
|
|
740
|
+
* it may never throw: a diff nobody can read is a missing SECTION of a readout, not
|
|
741
|
+
* a failed resume. Its absence is NAMED rather than rendered as an empty diff, which
|
|
742
|
+
* would read as "nothing changed" — the F6 direction that matters most here, since a
|
|
743
|
+
* person is about to mint a green off what this shows them.
|
|
744
|
+
*
|
|
745
|
+
* The patient is read AS IT STANDS NOW, not as it stood at the pause: a checkpoint
|
|
746
|
+
* lives 60 days and nothing freezes a tree. That is exactly why `accept` re-runs the
|
|
747
|
+
* mechanical stages (OPEN-3) instead of trusting what the human read.
|
|
748
|
+
* @param {string[]} paths the pause record's own changed set, used as a pathspec
|
|
749
|
+
*/
|
|
750
|
+
const DIFF_LINE_CAP = 200;
|
|
751
|
+
/** One renderer for the review screen's evidence, shared by the resume preview
|
|
752
|
+
* and the fresh-pause terminal so the fallback rule cannot drift between them:
|
|
753
|
+
* with a `hitl-pause` record, the package is the close's own ask + the diff of
|
|
754
|
+
* what changed; without one, say so plainly (per-site wording) rather than
|
|
755
|
+
* rendering the bare "approve?" ruling 2 forbids.
|
|
756
|
+
* @param {any} pause @param {string[]} fallbackLines */
|
|
757
|
+
const printPauseEvidence = (pause, fallbackLines) => {
|
|
758
|
+
if (!pause) {
|
|
759
|
+
for (const l of fallbackLines) out(l);
|
|
760
|
+
} else {
|
|
761
|
+
const paths = Array.isArray(pause.changed?.paths) ? pause.changed.paths : [];
|
|
762
|
+
for (const l of evidencePackage({ pause, diff: readDiff(paths) })) out(l);
|
|
763
|
+
}
|
|
764
|
+
};
|
|
765
|
+
|
|
766
|
+
const readDiff = (paths) => {
|
|
767
|
+
const run = (/** @type {string[]} */ a) => execFileSync('git', ['-C', wd, ...a], { encoding: 'utf8', maxBuffer: 16 * 1024 * 1024 });
|
|
768
|
+
try {
|
|
769
|
+
// after `--`, so a path that starts with a dash is a PATH and never a flag
|
|
770
|
+
const pathspec = paths.length ? ['--', ...paths] : [];
|
|
771
|
+
// secrets are scrubbed through the ONE inventory (src/validate.js), never a
|
|
772
|
+
// second spelling: a token sitting in a patient's diff must not be echoed onto
|
|
773
|
+
// a terminal or into whatever log is capturing it.
|
|
774
|
+
const raw = redactSecrets(run(['diff', '--unified=1', SEED, ...pathspec]));
|
|
775
|
+
const all = raw.split('\n').filter((l, i, a) => !(l === '' && i === a.length - 1));
|
|
776
|
+
const lines = all.slice(0, DIFF_LINE_CAP);
|
|
777
|
+
const others = run(['ls-files', '--others', '--exclude-standard']).split('\n').map((s) => s.trim()).filter(Boolean);
|
|
778
|
+
const untracked = paths.length ? others.filter((f) => paths.includes(f)) : others;
|
|
779
|
+
return { lines, truncated: Math.max(0, all.length - lines.length), untracked, unavailable: null };
|
|
780
|
+
} catch (e) {
|
|
781
|
+
return {
|
|
782
|
+
lines: [],
|
|
783
|
+
truncated: 0,
|
|
784
|
+
untracked: [],
|
|
785
|
+
unavailable: `${wd} could not be read against the seed ${SEED.slice(0, 12)} (${redactSecrets(String(e?.message ?? e)).split('\n')[0]})`,
|
|
786
|
+
};
|
|
787
|
+
}
|
|
788
|
+
};
|
|
789
|
+
/** F103 — is THIS leg a fresh engagement? A rerun is: the person commissioned new
|
|
790
|
+
* work at the moment they took the door, and they did not decide on the run's clock.
|
|
791
|
+
* The reading comes from the library's one resolver, never a second spelling here.
|
|
792
|
+
*
|
|
793
|
+
* BOTH DOORS are handed to it. A `--decide rerun` on a `--door` reaches the library
|
|
794
|
+
* as `doorRerun` rather than as a ruling (one flag, two questions), so it is passed
|
|
795
|
+
* in that slot here too — the `DOOR_RERUN` record itself is only built further down,
|
|
796
|
+
* after the library has answered the door, and a runner whose watchdog wall folded
|
|
797
|
+
* while the run's own clock did not would be the two counters disagreeing again. */
|
|
798
|
+
const FRESH_ENGAGEMENT = resolveHumanRuling(
|
|
799
|
+
RULING, HELD, DOOR !== null && RULING?.decision === 'rerun' ? RULING : null,
|
|
800
|
+
).fresh;
|
|
801
|
+
/** the restart runs on the REMAINDER of the signed wall, never a fresh allotment —
|
|
802
|
+
* "a budget ceiling folds in prior spend so re-invoking cannot silently widen it",
|
|
803
|
+
* in a time coat. Both the run's own clock and the outside watchdog read this one.
|
|
804
|
+
*
|
|
805
|
+
* F103 narrows where that is true. A RESUME of the same engagement folds, exactly as
|
|
806
|
+
* it always has (W-2: a kill may never buy a second wall). A RERUN does not: `msx7a3rj`
|
|
807
|
+
* took the door and inherited 87 seconds from a leg that had already ended, on a worker
|
|
808
|
+
* measured to read nine rounds before its first write — not a short engagement, a
|
|
809
|
+
* structurally impossible one, and it paid real money to find that out. MONEY still
|
|
810
|
+
* folds on every path (the `left` line below): the signed budget is the chain's
|
|
811
|
+
* ceiling, and a raise is a spec edit somebody signs. */
|
|
812
|
+
const RESUME_WALL_MS = WALL_MS === null || !dead ? WALL_MS
|
|
813
|
+
: (FRESH_ENGAGEMENT ? WALL_MS : Math.max(0, WALL_MS - dead.restart.priorWallMs));
|
|
814
|
+
|
|
815
|
+
/** does the named registry HOLD a quarantined green row for this runid under this
|
|
816
|
+
* workflow name? The door-preview's half of 2B: `quarantined` on the door record says
|
|
817
|
+
* the credit is held, and THIS says whether there is a row to release — the same two
|
|
818
|
+
* facts the run's own readout ANDs (`doorHere.quarantined && REGISTRY_ROW.minted`),
|
|
819
|
+
* recovered here by lookup because a preview is a separate invocation. Read-only, $0,
|
|
820
|
+
* fail-safe: no registry, an unreadable one, or no matching row all read as "nothing
|
|
821
|
+
* to release", never as a promise.
|
|
822
|
+
* @param {string|null} registryDir @param {string} name @param {string} runid */
|
|
823
|
+
function heldRowFor(registryDir, name, runid) {
|
|
824
|
+
if (registryDir === null || registryDir === '') return false;
|
|
825
|
+
return loadRegistry(registryDir).bridges.some((b) => b.name === name
|
|
826
|
+
&& (Array.isArray(b.history) ? b.history : []).some((h) => h.runid === runid && h.outcome === 'green' && h.quarantined === true));
|
|
827
|
+
}
|
|
828
|
+
|
|
829
|
+
// ── THE REVIEW DOOR's screen (module 8). Its OWN preview, deliberately not folded
|
|
830
|
+
// into the resume banner below: a door is answered about a run that is OVER, so
|
|
831
|
+
// none of that banner's arithmetic (what is left to resume with, which step it
|
|
832
|
+
// re-enters, whether the plan is doomed) is a true sentence here. What a person
|
|
833
|
+
// needs is the evidence, the three doors, and what each one costs.
|
|
834
|
+
if (doorSpineFile !== null && (ctx.approve ?? null) !== specHash) {
|
|
835
|
+
// doorSpineFile is computed FROM DOOR (`DOOR == null ? null : ...` above) and is
|
|
836
|
+
// non-null here, so DOOR itself cannot be null — this is a real invariant, stated
|
|
837
|
+
// for tsc rather than asserted away, and it can never actually throw.
|
|
838
|
+
if (DOOR === null) throw new Error('unreachable: doorSpineFile is only non-null when DOOR is non-null');
|
|
839
|
+
out('U — REVIEW DOOR, answering a run that has already ended');
|
|
840
|
+
out(` spec ${SPEC_DESC} $${spec.budgetUsd} wall ${WALL_LABEL}`);
|
|
841
|
+
out(` run ${DOOR} ${doorPrior?.spendComplete === false ? '≥' : ''}$${(doorPrior?.spentUsd ?? 0).toFixed(4)} spent · ${doorSpineFile}`);
|
|
842
|
+
out(` patient ${WORKDIR} @ ${SEED.slice(0, 12)}`);
|
|
843
|
+
out('');
|
|
844
|
+
for (const l of reviewDoorPackage({
|
|
845
|
+
door: doorRecord,
|
|
846
|
+
diff: readDiff(Array.isArray(doorRecord?.changed?.paths) ? doorRecord.changed.paths : []),
|
|
847
|
+
})) out(l);
|
|
848
|
+
out(`\n hash ${specHash}`);
|
|
849
|
+
if ((ctx.approve ?? null) !== null) err(`\nREFUSED: --approve ${(ctx.approve ?? null)} does not match this spec version.`);
|
|
850
|
+
// THE PREVIEW MAKES THE SAME PROMISE THE RUN'S OWN READOUT DOES (2B), read the
|
|
851
|
+
// same way: `quarantined` says the credit is HELD; whether there is anything to
|
|
852
|
+
// RELEASE is answered by the registry, not the class. The run's readout answers
|
|
853
|
+
// it off the row it just wrote; a preview is a separate invocation, so it asks
|
|
854
|
+
// the named registry whether a held green row for this runid actually exists —
|
|
855
|
+
// and the printed commands carry the registry forward, because an accept aimed
|
|
856
|
+
// at no registry is the `no-row-for-run` refusal one hop later.
|
|
857
|
+
const previewHeld = doorRecord?.quarantined === true && heldRowFor((ctx.registry ?? null), (ctx.workflow ?? null) ?? spec.job, DOOR);
|
|
858
|
+
const previewRegistry = (ctx.registry ?? null) !== null ? ` --registry ${(ctx.registry ?? null)} --workflow ${(ctx.workflow ?? null) ?? spec.job}` : '';
|
|
859
|
+
const doorInvoke = (/** @type {string} */ tail) => ` node scripts/run-u.mjs ${SELECTOR} --door ${DOOR}${tail}${previewRegistry} --approve ${specHash}`;
|
|
860
|
+
out('');
|
|
861
|
+
if (RULING === null) {
|
|
862
|
+
for (const l of runDoorLines({
|
|
863
|
+
rerun: doorInvoke(' --decide rerun --text "<what you want done differently>"').trim(),
|
|
864
|
+
accept: doorInvoke(' --decide accept').trim(),
|
|
865
|
+
pause: doorInvoke(' --decide pause').trim(),
|
|
866
|
+
ttlDays: PAUSE_TTL_MS / 86_400_000,
|
|
867
|
+
held: previewHeld,
|
|
868
|
+
})) out(l);
|
|
869
|
+
if (doorRecord?.quarantined === true && !previewHeld) {
|
|
870
|
+
out(' (this run\'s credit is HELD and the named registry holds no green row for it'
|
|
871
|
+
+ `${(ctx.registry ?? null) === null ? ' — no --registry was named' : ''}, so an accept records a disposition and releases nothing)`);
|
|
872
|
+
}
|
|
873
|
+
} else {
|
|
874
|
+
out(` decision ${RULING.decision}${RULING.decision === 'rerun' ? ' — and these words become the requirement the fresh engagement plans against:' : ''}`);
|
|
875
|
+
if (RULING.text !== null) for (const l of String(RULING.text).split('\n')) out(` ${l}`);
|
|
876
|
+
out(`\nTo approve and answer:\n${doorInvoke(` --decide ${RULING.decision}${RULING.text === null ? '' : ` --text ${JSON.stringify(RULING.text)}`}`)}`);
|
|
877
|
+
if (RULING.decision === 'rerun') {
|
|
878
|
+
out('\nA rerun COMMISSIONS WORK — launch it under a sleep inhibitor (a suspend freezes every guard, F72):');
|
|
879
|
+
out(' systemd-inhibit --what=idle:sleep --why="bareloop u run" env <the command above>');
|
|
880
|
+
}
|
|
881
|
+
}
|
|
882
|
+
throw new ExitSignal((ctx.approve ?? null) === null ? 0 : 1);
|
|
883
|
+
}
|
|
884
|
+
|
|
885
|
+
if ((ctx.approve ?? null) !== specHash) {
|
|
886
|
+
out(dead ? 'U — RESUME, continuing a halted run, REAL dollars' : 'U — user-mode e2e, ONE run, REAL dollars');
|
|
887
|
+
out(` spec ${SPEC_DESC} $${spec.budgetUsd} wall ${WALL_LABEL} strikeLimit=${STRIKE_LIMIT} (step ladder + close-fix progress rule)`);
|
|
888
|
+
out(` patient ${WORKDIR} @ ${SEED.slice(0, 12)}`);
|
|
889
|
+
out(` shim ${READ_SHIM_LABEL}`);
|
|
890
|
+
out(` scout ${SCOUT_LABEL}`);
|
|
891
|
+
out(` goal "${spec.goal}"`);
|
|
892
|
+
// F87 — the goal must state everything the close will judge, and nothing derives
|
|
893
|
+
// one from the other or checks them against each other. So the only defence is
|
|
894
|
+
// that the person signing reads both halves AT ONCE: this gate printed the goal
|
|
895
|
+
// and never the stages, and run-author printed the stages and never the goal.
|
|
896
|
+
// Names and kinds only; the params and notes are the authoring surface's reading
|
|
897
|
+
// (`scripts/author-readout.mjs`), and this one is about what the dollars below buy.
|
|
898
|
+
// Through `closeStagesOf` — THE ONE STAGING (src/plan.js), the same derivation the
|
|
899
|
+
// watchdog's stage count below already reads for the same reason. Gated on
|
|
900
|
+
// `spec.closeDecl` this printed the stages for exactly ONE of the eleven shipped
|
|
901
|
+
// specs: the other ten declare a command `close[]`, so the signer read the goal,
|
|
902
|
+
// then the hash, and never the stages that judge it — the F87 half-reading this
|
|
903
|
+
// block exists to end, reproduced by the block itself.
|
|
904
|
+
// A command stage carries no `kind` (that is the DECLARATION's vocabulary), and it
|
|
905
|
+
// is not a `command-exit` either — these stages gate on an exit code AND a judged
|
|
906
|
+
// floor — so it renders as `command` rather than borrowing a kind name it has not
|
|
907
|
+
// got. The spec here is read raw off disk and is not validateJob'd until runJob,
|
|
908
|
+
// so a stage is rendered defensively: a malformed entry must not throw the gate.
|
|
909
|
+
const stages = closeStagesOf(spec) ?? [];
|
|
910
|
+
if (stages.length) {
|
|
911
|
+
out(` judged ${stages.length} close stage(s) — this run is green only when EVERY one of them is, so the goal above has to name what they measure:`);
|
|
912
|
+
// direction is a deliberate visibility guard (2026-08-31 ruling) against the
|
|
913
|
+
// silent 'down' default: printed for every stage, never inferred, so the
|
|
914
|
+
// person signing sees which stages the close-fix governor reads as a rate
|
|
915
|
+
// (higher is better) rather than assuming every number is a count going down.
|
|
916
|
+
for (const s of stages) {
|
|
917
|
+
const dir = s?.direction === 'up' ? 'up (higher is better)' : 'down (lower is better, default)';
|
|
918
|
+
out(` ${s?.name ?? '(unnamed)'} [${s?.kind ?? 'command'}] direction ${dir}`);
|
|
919
|
+
}
|
|
920
|
+
}
|
|
921
|
+
if (dead) {
|
|
922
|
+
const rs = dead.restart;
|
|
923
|
+
out(` spine ${deadSpineFile} → stopped on ${dead.endOutcome ?? 'a halt'}`);
|
|
924
|
+
// D4a — SAY when the gate opened on a derivation rather than on the recorded name.
|
|
925
|
+
// The operator is looking at a spine that says `step-red`; without this line the
|
|
926
|
+
// resume reads as the gate having quietly stopped refusing.
|
|
927
|
+
if (dead.wallDerivedHalt) {
|
|
928
|
+
out(' recorded step-red RE-READ as wall-halt off this run\'s own wall-bounded record — the wall was crossed before it ended');
|
|
929
|
+
}
|
|
930
|
+
// the fold line says which of the two counters folds, per case: on an ordinary
|
|
931
|
+
// resume BOTH do (unchanged), and on a decide-time rerun the money still does
|
|
932
|
+
// while the wall does not (F103, spelled out in the `clock` line below).
|
|
933
|
+
out(` spent ${dead.spendComplete ? '' : '≥'}$${rs.priorSpentUsd.toFixed(4)} and ${(rs.priorWallMs / 60000).toFixed(1)}min before the halt — ${FRESH_ENGAGEMENT ? 'the MONEY is FOLDED IN, so this spends the REMAINDER; the wall is not (below)' : 'FOLDED IN, so this restarts on the REMAINDER'}`);
|
|
934
|
+
// F102 — SAY THAT THE ANSWER SURVIVED. Without this line the operator cannot
|
|
935
|
+
// tell a resume that will act on their words from one that will ask again, and
|
|
936
|
+
// that is the difference this whole rung exists for.
|
|
937
|
+
if (HELD) {
|
|
938
|
+
out(` held "${HELD.decision}"${HELD.text === null ? '' : ' — your own words, still the gap:'} answered ${HELD.receivedAt ?? 'at an unrecorded time'} and never paid for`);
|
|
939
|
+
if (HELD.text !== null) for (const l of String(HELD.text).split('\n')) out(` ${l}`);
|
|
940
|
+
out(' this resume APPLIES it — you are not asked again (F102)');
|
|
941
|
+
}
|
|
942
|
+
// F103 — and say which clock this leg gets, because a rerun's is not the
|
|
943
|
+
// remainder. The two counters are both printed rather than one number meaning
|
|
944
|
+
// both: chain so far, and what this engagement may spend.
|
|
945
|
+
if (FRESH_ENGAGEMENT && WALL_MS !== null) {
|
|
946
|
+
out(` clock FRESH ENGAGEMENT — a rerun is new work you commissioned, so it opens on the full signed ${WALL_MS / 60000}min rather than on what the corrected leg left (F103)`);
|
|
947
|
+
out(` chain so far ${(rs.priorWallMs / 60000).toFixed(1)}min — reported, never a bound on work you have just asked for`);
|
|
948
|
+
}
|
|
949
|
+
out(` left $${(spec.budgetUsd - rs.priorSpentUsd).toFixed(4)} of $${spec.budgetUsd}${WALL_MS === null ? '' : ` and ${(/** @type {number} */ (RESUME_WALL_MS) / 60000).toFixed(1)}min of ${WALL_MS / 60000}min`}`);
|
|
950
|
+
// WHERE it picks up. Without this line "resume" covers two runs that cost very
|
|
951
|
+
// different amounts — one that re-scouts and re-drafts from nothing, and one that
|
|
952
|
+
// re-enters at the close — and the hash being signed authorizes the dollars either way.
|
|
953
|
+
// …through `resumeAtLines` (src/u-readout.js), because a PAUSE is a phase this
|
|
954
|
+
// line did not have: it stops after the plan's steps, at the close's human stage, and
|
|
955
|
+
// the step arithmetic used to walk off the end of the plan (`at step 2 of 1
|
|
956
|
+
// "(unknown)"`). The rule the helper holds is that a step count may never exceed the
|
|
957
|
+
// plan, and that the phase is said in words.
|
|
958
|
+
for (const l of resumeAtLines({ seed: rs.seed, paused: dead.endOutcome === HITL_PAUSE })) out(l);
|
|
959
|
+
// WHAT IT INHERITS as a trend baseline. Without this line the resumed run's
|
|
960
|
+
// halt readout can name a direction the leg itself never measured, and a reader
|
|
961
|
+
// has no way to tell that the number came from the leg before it.
|
|
962
|
+
const gs = dead.restart.grades ?? [];
|
|
963
|
+
// grouped PER STAGE and rendered `stage a → b`, the same spelling src/trend.js's
|
|
964
|
+
// own readout uses — a second way of writing one series is a second way of reading
|
|
965
|
+
// it, and the accuracy law is that a series belongs to one stage
|
|
966
|
+
const byStage = new Map();
|
|
967
|
+
for (const g of gs) {
|
|
968
|
+
const k = g.stage ?? '(unstaged)';
|
|
969
|
+
if (!byStage.has(k)) byStage.set(k, []);
|
|
970
|
+
byStage.get(k).push(g.value);
|
|
971
|
+
}
|
|
972
|
+
if (gs.length) out(` trend ${gs.length} close grade(s) inherited as baselines (${[...byStage].map(([k, vs]) => `${k} ${vs.join(' → ')}`).join('; ')}) — the halt readout spans BOTH legs`);
|
|
973
|
+
else out(' trend no close grade to inherit — this leg\'s readout is judged on its own evidence');
|
|
974
|
+
// WHAT IT INHERITS AS A BOUND. The line above is a readout the leg carries; this is
|
|
975
|
+
// an allowance it does NOT get. A resumed leg that opens with both replans spent
|
|
976
|
+
// can only exhaust the plan it reloads — it cannot redraw it — and that changes
|
|
977
|
+
// what the dollars being signed here can buy. Stated only when there is something
|
|
978
|
+
// to state: a "0 replans spent" line on every cold-ish resume is noise that trains
|
|
979
|
+
// the eye to skip the line that matters.
|
|
980
|
+
if (rs.replans > 0) {
|
|
981
|
+
out(` replans ${rs.replans} already spent by this run — the ceiling is the RUN's (PRD v1.12), so this leg inherits it rather than a fresh one`
|
|
982
|
+
+ (rs.replanGrantUsed ? '; the arbiter\'s one extra is spent too' : '; the arbiter\'s one extra is still unearned'));
|
|
983
|
+
}
|
|
984
|
+
out(' patient continued AS THE RUN LEFT IT — NOT reset to the seed');
|
|
985
|
+
// The commonest resume there will ever be is the one straight after a money cut,
|
|
986
|
+
// and on THAT spine the remainder is zero or negative by definition — the run
|
|
987
|
+
// halted because the allowance was gone. Signing this hash unchanged buys an
|
|
988
|
+
// immediate re-halt. A minus sign three lines up is not a warning; this is.
|
|
989
|
+
const moneyLeft = spec.budgetUsd - rs.priorSpentUsd;
|
|
990
|
+
const timeLeft = RESUME_WALL_MS;
|
|
991
|
+
if (moneyLeft <= 0 || (timeLeft !== null && timeLeft <= 0)) {
|
|
992
|
+
out(' ⚠ NOTHING LEFT — this run halted because its allowance ran out, and nothing here refills it:');
|
|
993
|
+
if (moneyLeft <= 0) out(` budgetUsd $${spec.budgetUsd} is already spent (${moneyLeft < 0 ? `over by $${(-moneyLeft).toFixed(4)}` : 'exactly'})`);
|
|
994
|
+
if (timeLeft !== null && timeLeft <= 0) out(` maxWallMs ${/** @type {number} */ (WALL_MS) / 60000}min is already burnt`);
|
|
995
|
+
out(` RAISE the number(s) in ${SPEC_DESC} first — that is a spec edit, so the hash below changes and you sign the new one. Resuming as-is re-halts immediately for a close precheck's worth of nothing.`);
|
|
996
|
+
}
|
|
997
|
+
// F97 — the DOOMED SHAPE, which is the warning the line above cannot give. That
|
|
998
|
+
// one fires when the ALLOWANCE is gone; this one fires when the allowance is fine
|
|
999
|
+
// and the leg still cannot get anywhere: `u-msn0uccv` re-entered the exact plan
|
|
1000
|
+
// whose action was the diagnosed defect, with the replan ledger already spent, and
|
|
1001
|
+
// step-redded for $0.82 with $0.60 and 9.8 minutes still on the clock. WARNING
|
|
1002
|
+
// ONLY — a mechanical gap (a named wall, a count) converts on re-entry every time,
|
|
1003
|
+
// and only the operator can tell which kind of gap this is.
|
|
1004
|
+
if (doomedResume(rs)) {
|
|
1005
|
+
out(' ⚠ SAME PLAN, NO REPLANS — this resume re-enters the plan that already failed, and cannot redraw it:');
|
|
1006
|
+
out(' a resume does not re-draft (the spine says scout-skipped {reason:"resumed"}); the plan reloads byte-for-byte,');
|
|
1007
|
+
out(' and the replan ceiling shown above is spent, so the one channel that could replace it is gone.');
|
|
1008
|
+
out(' Fine if the gap is MECHANICAL (a named wall, a count) — doomed by construction if the PLAN is the defect: F97 paid $0.82 to find that out.');
|
|
1009
|
+
}
|
|
1010
|
+
if (dead.specHash && dead.specHash !== specHash) {
|
|
1011
|
+
out(` NOTE the halted run was signed under ${dead.specHash.slice(0, 12)}… and this spec hashes to ${specHash.slice(0, 12)}…`);
|
|
1012
|
+
out(' that is what a TOP-UP looks like: budgetUsd is in the hash, so raising it is a spec edit you sign below. The reloaded plan is re-validated against THIS spec and refused by name if it no longer fits.');
|
|
1013
|
+
}
|
|
1014
|
+
}
|
|
1015
|
+
// ── THE REVIEW (N4, ruling 2 + 2026-08-12 §5.2). This is the screen the whole
|
|
1016
|
+
// class exists for: the person is deciding, and they decide HERE, before any
|
|
1017
|
+
// signature. Never a bare "approve?" — the package is the close's own ask, every
|
|
1018
|
+
// mechanical stage's result, what the run changed and the lines it changed.
|
|
1019
|
+
if (PAUSED) {
|
|
1020
|
+
out('');
|
|
1021
|
+
// if the record that says WHAT a person was asked is gone: refusing would
|
|
1022
|
+
// strand real work, so say it, and let the operator decide with the spine
|
|
1023
|
+
// in front of them.
|
|
1024
|
+
printPauseEvidence(pauseRecord, [
|
|
1025
|
+
'HUMAN REVIEW — this run paused for a person, but its `hitl-pause` record is not on the spine, so there is',
|
|
1026
|
+
'no evidence package to show. Read the spine and the patient yourself before answering; nothing here can',
|
|
1027
|
+
`stand in for it: ${deadSpineFile}`,
|
|
1028
|
+
]);
|
|
1029
|
+
}
|
|
1030
|
+
out(` hash ${specHash}`);
|
|
1031
|
+
if ((ctx.approve ?? null) !== null) err(`\nREFUSED: --approve ${(ctx.approve ?? null)} does not match this spec version.`);
|
|
1032
|
+
// WHICH DOOR. A pause with no ruling yet is offered all three, rerun first
|
|
1033
|
+
// (2026-08-12 §4 — the ~40% rubber-stamp datum: the lean is toward the answer that
|
|
1034
|
+
// costs a cycle, never toward the one that mints a green nobody read). A pause WITH
|
|
1035
|
+
// a ruling is shown the ruling back — including the words that will BE the gap —
|
|
1036
|
+
// and one invocation to sign.
|
|
1037
|
+
// F187 — this hint used to hardcode ANTHROPIC_API_KEY, so a job on a
|
|
1038
|
+
// different provider (e.g. openai-api/DeepSeek) printed the WRONG variable
|
|
1039
|
+
// name to set: a person pasting it verbatim hit a $0 refusal naming the
|
|
1040
|
+
// right key only by accident of the runner's own generic error message,
|
|
1041
|
+
// never from this hint. `providerEntry.envKey` is the same resolved name
|
|
1042
|
+
// the real key check at launch (`:1157-1158` below) reads.
|
|
1043
|
+
const invoke = (/** @type {string} */ tail) => ` ${providerEntry.envKey}=... node scripts/run-u.mjs ${SELECTOR}${dead ? ` --resume ${RESUME}` : ''}${SHIM_TAIL}${SCOUT_TAIL}${tail} --approve ${specHash}`;
|
|
1044
|
+
/** the door the operator has already picked, as flags — hoisted out of the else
|
|
1045
|
+
* below so the inhibitor line at the bottom can print the WHOLE command rather
|
|
1046
|
+
* than a shape the operator has to assemble. Empty on an ordinary run and on the
|
|
1047
|
+
* pause that has not been ruled on yet (there the three doors are the answer). */
|
|
1048
|
+
const decisionTail = RULING === null ? ''
|
|
1049
|
+
: ` --decide ${RULING.decision}${RULING.text === null ? '' : ` --text ${JSON.stringify(RULING.text)}`}`;
|
|
1050
|
+
if (PAUSED && RULING === null) {
|
|
1051
|
+
out('');
|
|
1052
|
+
for (const l of doorLines({
|
|
1053
|
+
rerun: invoke(' --decide rerun --text "<what you want done differently>"').trim(),
|
|
1054
|
+
accept: invoke(' --decide accept').trim(),
|
|
1055
|
+
pause: invoke(' --decide pause').trim(),
|
|
1056
|
+
})) out(l);
|
|
1057
|
+
} else {
|
|
1058
|
+
if (RULING !== null) {
|
|
1059
|
+
out(`\n decision ${RULING.decision}${RULING.decision === 'rerun' ? ' — and these words become the gap the fix worker converts from:' : ''}`);
|
|
1060
|
+
if (RULING.text !== null) for (const l of String(RULING.text).split('\n')) out(` ${l}`);
|
|
1061
|
+
}
|
|
1062
|
+
out(`\nTo approve and run:\n${invoke(decisionTail)}`);
|
|
1063
|
+
}
|
|
1064
|
+
// THE INHIBITOR, printed rather than remembered (F72). A suspend freezes
|
|
1065
|
+
// EVERYTHING: the outside watchdog is a poller, so it cannot observe — let alone
|
|
1066
|
+
// kill — a run whose machine is asleep, and since hamr's 2026-08-15 ruling the
|
|
1067
|
+
// run's own wall does not count those minutes either (src/clock.js is monotonic
|
|
1068
|
+
// now). So a suspended run is simply unwatched for as long as it sleeps. This is
|
|
1069
|
+
// the moment the operator can still do something about it, and run-reuse.mjs has
|
|
1070
|
+
// printed the same line here since F72 was minted; the U runner carried it only as
|
|
1071
|
+
// a comment, which is a rule nobody reads at the moment they launch.
|
|
1072
|
+
out('\nLaunch under a sleep inhibitor — a suspend freezes every guard, including the outside watchdog (F72):');
|
|
1073
|
+
out(PAUSED && RULING === null
|
|
1074
|
+
// a pause with no ruling is choosing between three doors above; prefixing one of
|
|
1075
|
+
// them would quietly recommend it, and the lean this surface was built with runs
|
|
1076
|
+
// the other way (rerun first, never the rubber stamp)
|
|
1077
|
+
? ' systemd-inhibit --what=idle:sleep --why="bareloop u run" env <the door you picked, above>'
|
|
1078
|
+
// `env` before the assignment on purpose: systemd-inhibit execs a COMMAND, and a
|
|
1079
|
+
// bare `VAR=value` prefix is shell syntax it would try to run as one. The key
|
|
1080
|
+
// still rides an env assignment and never argv (it must never reach a cmdline).
|
|
1081
|
+
: ` systemd-inhibit --what=idle:sleep --why="bareloop u run" env \\\n ${invoke(decisionTail).trim()}`);
|
|
1082
|
+
throw new ExitSignal((ctx.approve ?? null) === null ? 0 : 1);
|
|
1083
|
+
}
|
|
1084
|
+
|
|
1085
|
+
// ── THE REVIEW DOOR, ANSWERED (module 8). Past the approval gate, so the signer
|
|
1086
|
+
// proof is the same one every other decision rides: the spec hash they approved.
|
|
1087
|
+
//
|
|
1088
|
+
// The rulebook is the LIBRARY's (`answerReviewDoor`) and nothing is re-decided
|
|
1089
|
+
// here: it validates the door, re-proves the tree for an `accept`, records the
|
|
1090
|
+
// disposition and releases a held judged green. This prints what it did.
|
|
1091
|
+
//
|
|
1092
|
+
// `accept` and `pause` LAUNCH NOTHING and exit. `rerun` falls through into the run
|
|
1093
|
+
// below as a FRESH ENGAGEMENT (§3.4): its own clock, and what REMAINS of the signed
|
|
1094
|
+
// budget — money folds on every path, time does not.
|
|
1095
|
+
/** @type {{text: string, fromRunid: string, receivedAt: string}|null} */
|
|
1096
|
+
let DOOR_RERUN = null;
|
|
1097
|
+
if (doorSpineFile !== null) {
|
|
1098
|
+
if (RULING === null) {
|
|
1099
|
+
err('NO DECISION — this run is standing at its review door and no decision was given.');
|
|
1100
|
+
err('Answer it with one of: --decide rerun --text "<your words>" · --decide accept · --decide pause');
|
|
1101
|
+
err('(the same command without --approve prints the evidence package — that is the screen this decision is made on)');
|
|
1102
|
+
throw new ExitSignal(2);
|
|
1103
|
+
}
|
|
1104
|
+
const answeredAt = new Date().toISOString();
|
|
1105
|
+
// PRD item 27/M3 — the door's mechanical re-run needs the same effective
|
|
1106
|
+
// ceiling the run itself would use; resolved through the identical
|
|
1107
|
+
// function (`resolveCloseTimeoutMs`), never a second guess. Paid only on
|
|
1108
|
+
// an `accept` decision's re-proof (`proveMechanically`, src/reviewdoor.js);
|
|
1109
|
+
// a `rerun`/`pause` decision spends nothing here either way.
|
|
1110
|
+
const doorCloseTiming = await resolveCloseTimeoutMs({ job: spec, stages: closeStagesOf(spec) ?? [], cwd: wd, redact: redactSecrets });
|
|
1111
|
+
// hamr's ruling 2026-09-07 (docs/product/CLOSE-INTEGRITY-BUILD.md, "Ruled
|
|
1112
|
+
// 2026-09-07") — a door whose own timing pass times out must REFUSE the
|
|
1113
|
+
// accept outright as a named `close-timing-red` door stop, mirroring the
|
|
1114
|
+
// in-run escalation below (~line 1173): falling through to the library's
|
|
1115
|
+
// 120s default (the pre-fix shape: `closeTimeoutMs: undefined`) silently
|
|
1116
|
+
// substituted an unauthorized ceiling for a door decision. `answerReviewDoor`
|
|
1117
|
+
// is never called on this path — nothing is recorded (no door record on
|
|
1118
|
+
// `doorSpineFile`), nothing is released, and nothing is spent.
|
|
1119
|
+
if (doorCloseTiming.timedOut) {
|
|
1120
|
+
// `resolveCloseTimeoutMs` only returns `timing: null` on the signed-override
|
|
1121
|
+
// path, which returns `timedOut: false` — so `timedOut: true` always carries a
|
|
1122
|
+
// real `timing`. Stated for tsc, not asserted away.
|
|
1123
|
+
if (doorCloseTiming.timing === null) throw new Error('unreachable: resolveCloseTimeoutMs sets timing whenever timedOut is true');
|
|
1124
|
+
const names = doorCloseTiming.timing.perStage.filter((s) => s.timedOut).map((s) => s.name).join(', ');
|
|
1125
|
+
for (const l of doorTimingRedLines({ names })) err(l);
|
|
1126
|
+
throw new ExitSignal(1);
|
|
1127
|
+
}
|
|
1128
|
+
// Symmetric invariant: every non-timedOut return of `resolveCloseTimeoutMs` sets a
|
|
1129
|
+
// real `closeTimeoutMs` (override or estimated) — `null` only pairs with
|
|
1130
|
+
// `timedOut: true`, already handled above.
|
|
1131
|
+
if (doorCloseTiming.closeTimeoutMs === null) throw new Error('unreachable: resolveCloseTimeoutMs sets closeTimeoutMs whenever timedOut is false');
|
|
1132
|
+
const ans = await answerReviewDoor({
|
|
1133
|
+
job: spec,
|
|
1134
|
+
workdir: wd,
|
|
1135
|
+
events: doorEvents ?? [],
|
|
1136
|
+
decision: RULING.decision,
|
|
1137
|
+
text: RULING.text,
|
|
1138
|
+
closeTimeoutMs: doorCloseTiming.closeTimeoutMs,
|
|
1139
|
+
closeDir: spineDir,
|
|
1140
|
+
at: answeredAt,
|
|
1141
|
+
// THE REGISTRY IS OPTIONAL AND NEVER CONJURED. This runner keeps standalone
|
|
1142
|
+
// bridge FILES, not a registry, so a release has nothing to write unless the
|
|
1143
|
+
// operator names one. Absent, the answer is recorded on the run's own spine and
|
|
1144
|
+
// the readout SAYS the credit was not released — never a silent no-op.
|
|
1145
|
+
registryDir: (ctx.registry ?? null),
|
|
1146
|
+
name: (ctx.workflow ?? null) ?? spec.job,
|
|
1147
|
+
runid: DOOR,
|
|
1148
|
+
// the decision goes onto the answered run's OWN append-only log: the record of
|
|
1149
|
+
// what a person decided about a run belongs beside the record of the run
|
|
1150
|
+
emit: makeSpine(doorSpineFile, { startSeq: doorEvents?.at(-1)?.seq ?? 0 }),
|
|
1151
|
+
});
|
|
1152
|
+
if (!ans.ok) {
|
|
1153
|
+
err(`\nDOOR REFUSED (${RULING.decision}) — nothing was recorded and nothing was released.`);
|
|
1154
|
+
for (const r of ans.reds) err(` ${r.code}:${r.path} — ${r.detail}`);
|
|
1155
|
+
if (ans.mechanical?.stages?.length) {
|
|
1156
|
+
err(' the mechanical re-run, stage by stage:');
|
|
1157
|
+
for (const s of ans.mechanical.stages) err(` ${s.name} ${s.verdict}`);
|
|
1158
|
+
}
|
|
1159
|
+
throw new ExitSignal(2);
|
|
1160
|
+
}
|
|
1161
|
+
if (RULING.decision !== 'rerun') {
|
|
1162
|
+
out(`\n${RULING.decision === 'accept' ? 'ACCEPTED' : 'PAUSED'} — the verdict this run minted (${doorRecord.outcome}) is UNTOUCHED; what you answered is what happens to the work.`);
|
|
1163
|
+
if (RULING.decision === 'accept') {
|
|
1164
|
+
out(` proved ${ans.mechanical?.ran ?? 0} mechanical stage(s) re-ran on the tree as it stands, and passed — an accept is not a rubber stamp`);
|
|
1165
|
+
out(` credit ${ans.released ? 'RELEASED — this workflow is now eligible for reuse' : ((ctx.registry ?? null) === null ? 'not released: no --registry was named, so there is no entry to release (the answer is on the spine)' : (quarantinesCredit(spec.verdictType) ? 'nothing left to release — this row was already released by an earlier accept (a repeat answer records nothing new)' : 'nothing was held to release — a green-class row is minted born RELEASED (hamr, 2026-08-30): a machine proved it, so there was never a credit to quarantine'))}`);
|
|
1166
|
+
if (ans.note) out(` note ${ans.note}`);
|
|
1167
|
+
} else {
|
|
1168
|
+
out(` costs nothing, in any state — no work, no money, no allowance moved`);
|
|
1169
|
+
out(` keeps ${PAUSE_TTL_MS / 86_400_000} days from the door on the record; after that it expires on its own, which is all "cancel" ever meant`);
|
|
1170
|
+
out(` reopen node scripts/run-u.mjs ${SELECTOR} --door ${DOOR} --approve ${specHash}`);
|
|
1171
|
+
}
|
|
1172
|
+
throw new ExitSignal(0);
|
|
1173
|
+
}
|
|
1174
|
+
DOOR_RERUN = { text: /** @type {string} */ (RULING.text), fromRunid: /** @type {string} */ (DOOR), receivedAt: answeredAt };
|
|
1175
|
+
out(`\nRERUN — a FRESH ENGAGEMENT against the same signed spec. The previous run's verdict (${doorRecord.outcome}) is untouched.`);
|
|
1176
|
+
out(` clock its own: the full signed wall (${WALL_LABEL}) — a person does not decide on the run's clock (F103)`);
|
|
1177
|
+
out(` money what REMAINS of the signed $${spec.budgetUsd}: ${doorPrior?.spendComplete === false ? '≥' : ''}$${(doorPrior?.spentUsd ?? 0).toFixed(4)} is already spent and folds in (a signature for $${spec.budgetUsd} never authorises more)`);
|
|
1178
|
+
out(' gap your words, carried to the PLANNER as a requirement on top of the goal');
|
|
1179
|
+
}
|
|
1180
|
+
|
|
1181
|
+
// ── THE PAUSE DOOR (2026-08-17) — answered HERE, and it launches nothing.
|
|
1182
|
+
//
|
|
1183
|
+
// hamr's ruling replaced cancel with a pause that can resume, and the honest way to
|
|
1184
|
+
// keep a checkpoint is to leave it alone. This runner writes one spine file per LEG,
|
|
1185
|
+
// and a leg that returns before drafting emits no `plan-accepted` and no `step-end` —
|
|
1186
|
+
// which is exactly what `readStepCheckpoint` reads. So launching a run to say "not
|
|
1187
|
+
// now" would mint a NEW runid whose own checkpoint is empty, and an operator who
|
|
1188
|
+
// later resumed that runid would re-draft and re-pay for every step the paused leg
|
|
1189
|
+
// already finished. The checkpoint that matters is the one already on disk.
|
|
1190
|
+
//
|
|
1191
|
+
// So the decision is recorded to the operator, in words, against the runid they
|
|
1192
|
+
// resume — and nothing is spent, nothing is signed away, and no allowance moves. The
|
|
1193
|
+
// library keeps its own half (`runPlan` mints a `hitl-pause` with `humanDecision`
|
|
1194
|
+
// when an adopter drives the door directly); this is the runner's, and the two agree
|
|
1195
|
+
// on the one fact that matters: the run stays paused and stays resumable.
|
|
1196
|
+
//
|
|
1197
|
+
// IT SITS ABOVE THE WALL GATE, and that placement is a ruling (v1.73 addendum 2,
|
|
1198
|
+
// ruling 5): *the pause door is ALLOWANCE-FREE in every state, including below the
|
|
1199
|
+
// wall-exhausted gate*. Pause does no work and spends no money, so there is nothing
|
|
1200
|
+
// for an allowance to pay for, and charging one would be the door billing a person
|
|
1201
|
+
// for declining to decide. A raise-and-re-sign is what changes what a RERUN can buy;
|
|
1202
|
+
// it was never what a pause costs. `accept` and `rerun` still meet the wall below —
|
|
1203
|
+
// they commission work, and work meets the clock.
|
|
1204
|
+
if (PAUSED && RULING?.decision === 'pause') {
|
|
1205
|
+
out(`\nPAUSED BY YOU — nothing was run and nothing was spent. The checkpoint stands exactly as it was: the work is on the run's own branch, the plan and the money are where the paused leg left them.`);
|
|
1206
|
+
out(` keeps ${PAUSE_TTL_MS / 86_400_000} days from the pause on the record — after that the checkpoint expires on its own, and nothing has to be decided today to let that happen`);
|
|
1207
|
+
out(' resume the SAME runid, whenever you want, with the door you pick then:');
|
|
1208
|
+
out(` node scripts/run-u.mjs ${SELECTOR} --resume ${RESUME}${SHIM_TAIL}${SCOUT_TAIL} --decide accept --approve ${specHash}`);
|
|
1209
|
+
out(` node scripts/run-u.mjs ${SELECTOR} --resume ${RESUME}${SHIM_TAIL}${SCOUT_TAIL} --decide rerun --text "<what you want done differently>" --approve ${specHash}`);
|
|
1210
|
+
out(` read the same command with no --decide re-prints the evidence package you just looked at`);
|
|
1211
|
+
throw new ExitSignal(0);
|
|
1212
|
+
}
|
|
1213
|
+
|
|
1214
|
+
// W-2, on the launch side: "when time is up, keep the grade we already have and stop".
|
|
1215
|
+
// A resume whose wall remainder is zero or negative has no time to start anything in,
|
|
1216
|
+
// and launching it buys a scout and a precheck's worth of nothing before the clock
|
|
1217
|
+
// halts it. The preview above already warns; this REFUSES, because the warning is
|
|
1218
|
+
// advisory and this is the wall itself. It also closes the guard's half of the same
|
|
1219
|
+
// hole: `--wall-ms 0` used to reach u-watchdog, which defaulted it to null and armed
|
|
1220
|
+
// no deadline at all while this banner still claimed a wall.
|
|
1221
|
+
if (dead && RESUME_WALL_MS !== null && RESUME_WALL_MS <= 0) {
|
|
1222
|
+
err(`WALL ALREADY EXHAUSTED — the halted run burned ${(dead.restart.priorWallMs / 60000).toFixed(1)}min of the signed ${/** @type {number} */ (WALL_MS) / 60000}min, so this resume starts with no time at all.`);
|
|
1223
|
+
err('Nothing here refills it (a run may never widen its own cap). The lever is yours:');
|
|
1224
|
+
err(` - RAISE maxWallMs in ${SPEC_DESC} — that is a spec edit, so the hash changes and you sign the new one with --approve;`);
|
|
1225
|
+
err(' - or revise the goal/spec, or abandon the run and keep the verdict it already minted.');
|
|
1226
|
+
// NAMED, because it is a real and non-obvious consequence rather than an oversight:
|
|
1227
|
+
// a PAUSED run whose wall is gone cannot be ACCEPTED — an accept re-runs every
|
|
1228
|
+
// mechanical stage against the tree as it stands (OPEN-3), which is work, and work
|
|
1229
|
+
// meets the clock. The wall is governance and this runner does not get to decide
|
|
1230
|
+
// that some decisions are free of it.
|
|
1231
|
+
//
|
|
1232
|
+
// The one door that IS free of it is `pause`, answered above and never reaching
|
|
1233
|
+
// here (v1.73 ruling 5). That is not this script relaxing a governance gate: a pause
|
|
1234
|
+
// runs nothing and spends nothing, so there is no allowance for it to be charged
|
|
1235
|
+
// against, and the checkpoint it keeps is the one already on disk.
|
|
1236
|
+
if (PAUSED) {
|
|
1237
|
+
err(' NOTE: this run is waiting on a DECISION and its wall is gone, so accept and rerun cannot be applied until the wall is.');
|
|
1238
|
+
err(` --decide pause is still open and always is: it costs nothing in any state, and the checkpoint keeps for ${PAUSE_TTL_MS / 86_400_000} days.`);
|
|
1239
|
+
}
|
|
1240
|
+
throw new ExitSignal(2);
|
|
1241
|
+
}
|
|
1242
|
+
|
|
1243
|
+
// A PAUSE is answered, or it is not resumed. The lean-rerun rule (2026-08-12 §4) is
|
|
1244
|
+
// about which door a prompt LEADS with — it is never an action taken for the
|
|
1245
|
+
// operator, so there is no default here to fall through to. Resuming a pause with no
|
|
1246
|
+
// ruling would re-run the close and pause again at the same stage, buying a precheck
|
|
1247
|
+
// to ask the same question twice.
|
|
1248
|
+
if (PAUSED && RULING === null) {
|
|
1249
|
+
err('NO DECISION — this resume continues a run that is waiting on a person, and no decision was given.');
|
|
1250
|
+
err('Answer it with one of: --decide rerun --text "<your words>" · --decide accept · --decide pause');
|
|
1251
|
+
err('(run the same command without --approve to read the evidence package first — that is the screen this decision is made on)');
|
|
1252
|
+
throw new ExitSignal(2);
|
|
1253
|
+
}
|
|
1254
|
+
|
|
1255
|
+
|
|
1256
|
+
// TWO KEYS, TWO ROLES, AND ONLY THE ONES THIS JOB WILL USE (PRD item 31.4).
|
|
1257
|
+
//
|
|
1258
|
+
// Before this, `ANTHROPIC_API_KEY` was demanded UNCONDITIONALLY — so a green
|
|
1259
|
+
// job whose worker is DeepSeek or Gemini, and whose close is pure mechanical
|
|
1260
|
+
// commands, still hard-exited without an Anthropic key it would never spend a
|
|
1261
|
+
// token against. That made "provider-agnostic" half-true: you could run on
|
|
1262
|
+
// another provider only if you also held an Anthropic account.
|
|
1263
|
+
//
|
|
1264
|
+
// The WORKER's key comes from the provider's own `envKey` (the factory's, so
|
|
1265
|
+
// there is one table saying which provider reads which variable) and is
|
|
1266
|
+
// ALWAYS required — the worker always runs.
|
|
1267
|
+
//
|
|
1268
|
+
// The JUDGE's key is required only when the close actually judges
|
|
1269
|
+
// (`closeJudges`, src/kinds.js — the ONE reading of that question), and since
|
|
1270
|
+
// PRD item 32 the judge is no longer pinned to Anthropic: its identity is
|
|
1271
|
+
// RESOLVED below, and its key follows that resolved provider's own env var with
|
|
1272
|
+
// `JUDGE_API_KEY` as a role-named override in front. One variable used to do two
|
|
1273
|
+
// jobs (worker key for an anthropic-api job AND judge key for every job), which
|
|
1274
|
+
// was confusing while the judge was pinned and would be a plain lie now that it
|
|
1275
|
+
// is not.
|
|
1276
|
+
//
|
|
1277
|
+
// A green anthropic-api job's behaviour is byte-identical to before: its
|
|
1278
|
+
// worker key IS `ANTHROPIC_API_KEY`, demanded as ever.
|
|
1279
|
+
const JUDGES = closeJudges(spec.closeDecl);
|
|
1280
|
+
// WHICH MODEL GRADES THIS JOB (PRD item 32.1). Resolved, never pinned: the
|
|
1281
|
+
// spec's signed `judge: {provider, model}` if it names one, else this job's own
|
|
1282
|
+
// worker provider and model — so a DeepSeek or Gemini job judges on its own
|
|
1283
|
+
// provider and needs no second account. `resolveJudge` throws on a malformed
|
|
1284
|
+
// override rather than quietly falling back to the worker's; the spec validator
|
|
1285
|
+
// has already redded that shape, so reaching the throw means a caller skipped
|
|
1286
|
+
// validation. Resolved UNCONDITIONALLY (needs no key) — `judge.model` rides
|
|
1287
|
+
// into runJob below whether this leg constructs a real judge provider or not.
|
|
1288
|
+
const judge = resolveJudge({ specJudge: spec.judge, workerProvider: spec.provider, workerModel: MODEL });
|
|
1289
|
+
const judgeEntry = resolveProvider(judge.provider);
|
|
1290
|
+
// undefined, not null: both come straight off `env[...]` lookups below, which
|
|
1291
|
+
// (like process.env) never produce null for a missing key.
|
|
1292
|
+
/** @type {string|undefined} */
|
|
1293
|
+
let workerApiKey;
|
|
1294
|
+
/** @type {string|undefined} */
|
|
1295
|
+
let judgeApiKey;
|
|
1296
|
+
// TEST SEAM (constraint: `deps.provider` skips the real-key check entirely,
|
|
1297
|
+
// the same shape src/cli.js's `doRun` already uses) — a caller that hands in
|
|
1298
|
+
// its own provider IS the run, so nothing here needs a real secret.
|
|
1299
|
+
if (!deps.provider) {
|
|
1300
|
+
// A green anthropic-api job's behaviour is byte-identical to before: its
|
|
1301
|
+
// worker key IS `ANTHROPIC_API_KEY`, demanded as ever.
|
|
1302
|
+
workerApiKey = env[providerEntry.envKey];
|
|
1303
|
+
if (!workerApiKey) { err(`${providerEntry.envKey} not set (secrets load from the environment — never the tree)`); throw new ExitSignal(2); }
|
|
1304
|
+
// F181 — a key that reads as "set" above can still carry a line break/
|
|
1305
|
+
// control char/stray whitespace (a two-line secret-store entry, e.g.) and
|
|
1306
|
+
// crash Node's own header-encode inside the paid span. Refuse at the SAME
|
|
1307
|
+
// door, before any provider is constructed; never echo the value.
|
|
1308
|
+
const workerKeyProblem = apiKeyProblem(workerApiKey);
|
|
1309
|
+
if (workerKeyProblem) { err(`${providerEntry.envKey} ${workerKeyProblem} — refusing rather than crashing mid-call (never trimmed or repaired; fix the value at its source)`); throw new ExitSignal(2); }
|
|
1310
|
+
// The judge's KEY follows the RESOLVED judge provider's own env var, with
|
|
1311
|
+
// `JUDGE_API_KEY` as a role-named override in front of it (PRD item 32.3). The
|
|
1312
|
+
// override matters most in the case this item exists for: a job whose worker and
|
|
1313
|
+
// judge are the SAME provider but different accounts, and the anthropic case
|
|
1314
|
+
// where one variable used to do two jobs.
|
|
1315
|
+
judgeApiKey = env.JUDGE_API_KEY ?? env[judgeEntry.envKey];
|
|
1316
|
+
if (JUDGES && !judgeApiKey) {
|
|
1317
|
+
err(`This job's close JUDGES (${judgedStages(spec.closeDecl).length} judged stage(s)) and its judge is ${judge.model} on ${judge.provider}${spec.judge ? ' (signed override)' : " (this job's own worker model — no `judge` override signed)"}.`);
|
|
1318
|
+
err(`Neither JUDGE_API_KEY nor ${judgeEntry.envKey} is set — secrets load from the environment, never the tree.`);
|
|
1319
|
+
err('Refused at $0, BEFORE the worker spends anything: a judged run that discovers this at the close has paid for a verdict it cannot render.');
|
|
1320
|
+
throw new ExitSignal(2);
|
|
1321
|
+
}
|
|
1322
|
+
// F181 — same door, second shape: a judge key that IS present can still be
|
|
1323
|
+
// malformed (line break/control char/stray whitespace). Checked only when
|
|
1324
|
+
// JUDGES (the key is only required then); never echoes the value.
|
|
1325
|
+
if (JUDGES) {
|
|
1326
|
+
const judgeKeyProblem = apiKeyProblem(/** @type {string} */ (judgeApiKey));
|
|
1327
|
+
if (judgeKeyProblem) {
|
|
1328
|
+
const judgeEnvName = env.JUDGE_API_KEY ? 'JUDGE_API_KEY' : judgeEntry.envKey;
|
|
1329
|
+
err(`${judgeEnvName} ${judgeKeyProblem} — refusing rather than crashing mid-call (never trimmed or repaired; fix the value at its source)`);
|
|
1330
|
+
throw new ExitSignal(2);
|
|
1331
|
+
}
|
|
1332
|
+
}
|
|
1333
|
+
}
|
|
1334
|
+
|
|
1335
|
+
// `wd`/`spineDir` are derived once, above the resume reader that needs them; only the
|
|
1336
|
+
// directory's CREATION belongs here, after the preview/approval gates have exited.
|
|
1337
|
+
mkdirSync(spineDir, { recursive: true });
|
|
1338
|
+
const runid = Date.now().toString(36);
|
|
1339
|
+
const spineFile = join(spineDir, `u-${runid}.jsonl`);
|
|
1340
|
+
|
|
1341
|
+
// PANEL-BUILD.md P1 — one row in the run list, BEFORE any paid call (this
|
|
1342
|
+
// leg's own `runJob` further down, and BEFORE the provider-key checks that
|
|
1343
|
+
// preceded this point already gated on). `runid` is fresh for THIS leg
|
|
1344
|
+
// (cold start or resumed leg both mint one here), so `appendRun`'s own
|
|
1345
|
+
// runid-dedup never fires on the ordinary path — it exists for a caller
|
|
1346
|
+
// that legitimately re-enters with the same runid (e.g. a retried
|
|
1347
|
+
// in-process test). A list-append failure must never block a real,
|
|
1348
|
+
// already-signed run: caught and named loudly, never rethrown.
|
|
1349
|
+
// `deps.runlistHome` — the same injectable test seam `ctx.deps` already
|
|
1350
|
+
// carries everything else through (env/out/err/provider/…): a caller that
|
|
1351
|
+
// injects it writes this leg's row under a temp home instead of the real
|
|
1352
|
+
// `~/.config/bareloop`; production never sets it.
|
|
1353
|
+
try {
|
|
1354
|
+
appendRun({
|
|
1355
|
+
at: new Date().toISOString(), runid, job: spec.job, spine: spineFile, patient: wd, via: 'run-u',
|
|
1356
|
+
}, { home: deps.runlistHome });
|
|
1357
|
+
} catch (/** @type {any} */ e) {
|
|
1358
|
+
err(`WARNING: could not add this run to ~/.config/bareloop/runs.jsonl (${e.message}) — the run continues; the panel's list will be missing this row.`);
|
|
1359
|
+
}
|
|
1360
|
+
|
|
1361
|
+
const git = (/** @type {string[]} */ a) => execFileSync('git', ['-C', wd, ...a], { encoding: 'utf8' }).trim();
|
|
1362
|
+
if (dead) {
|
|
1363
|
+
// A RESUME is the OPPOSITE case from a cold launch: the halted run's edits are work
|
|
1364
|
+
// this budget already paid for, and resetting them would throw away exactly what
|
|
1365
|
+
// resume exists to keep. A dirty tree is what a resume expects to find. Only a MOVED
|
|
1366
|
+
// HEAD stops it — nothing in a run commits, so that means a human did, and the
|
|
1367
|
+
// reconstruction's premise (this tree is where the run left it) no longer holds.
|
|
1368
|
+
// The `.litectx` store is kept for the same reason: it is paid indexing, and the
|
|
1369
|
+
// COLD-MEANS-COLD reset below is about a fresh baseline, which this is not.
|
|
1370
|
+
const gate = resumeTreeGate({ head: git(['rev-parse', 'HEAD']), seed: SEED, dirty: git(['status', '--porcelain']) });
|
|
1371
|
+
if (!gate.ok) {
|
|
1372
|
+
err(`PATIENT REFUSED — ${gate.detail}`);
|
|
1373
|
+
err('This is a decision for you, not the harness: reset and start fresh, or put HEAD back where the halted run left it and resume again.');
|
|
1374
|
+
throw new ExitSignal(2);
|
|
1375
|
+
}
|
|
1376
|
+
out(`patient ${gate.detail ?? 'clean — the halted run left no uncommitted work'} (store kept: it is indexing this run already paid for)`);
|
|
1377
|
+
} else {
|
|
1378
|
+
// the patient starts at the seed, every time — a run that inherits the previous
|
|
1379
|
+
// run's edits is measuring the wrong thing. COLD MEANS COLD (P design record): the
|
|
1380
|
+
// isolate verbs (stash/remember) persist in .litectx across runs, so an uncleaned
|
|
1381
|
+
// store would leak run N's memory into run N+1's "cold" baseline and quietly poison
|
|
1382
|
+
// every contrast (the reuse rung's OFF arm above all). The store is a derived,
|
|
1383
|
+
// self-healing cache by litectx's own contract; the re-index it costs is ~65s under
|
|
1384
|
+
// yield. When the reuse rung lands, KEEPING the store becomes an explicit
|
|
1385
|
+
// ledger-attributed choice — never a leak.
|
|
1386
|
+
//
|
|
1387
|
+
// The mechanism lives in `src/u-patient.js` so a battery driver rehearsing a
|
|
1388
|
+
// cold row runs THIS reset rather than a second spelling of it.
|
|
1389
|
+
const cold = coldReset(wd, SEED);
|
|
1390
|
+
out(`patient reset — clean at ${cold.head}, store ${cold.storeRemoved ? 'removed (cold)' : 'was already absent (cold)'}`);
|
|
1391
|
+
// F186 — AFTER coldReset and BEFORE this run's own Gate ever opens (which
|
|
1392
|
+
// writes to this same path), any gate-audit.jsonl still sitting in the
|
|
1393
|
+
// tree is provably not this run's (see moveStaleGateAudit's own doc) —
|
|
1394
|
+
// move it aside now, so the end-of-run rename below (`gate-audit.jsonl`
|
|
1395
|
+
// -> `u-<runid>-gate-audit.jsonl`) only ever carries THIS run's rows. Not
|
|
1396
|
+
// called on the RESUME (`dead`) branch above — a halted run's own gate
|
|
1397
|
+
// audit already in the tree it resumes into is that SAME run's prior leg.
|
|
1398
|
+
const preFile = moveStaleGateAudit(wd, spineDir, runid);
|
|
1399
|
+
if (preFile) out(`gate audit — a stale file was already sitting in the patient tree (not this run's); moved aside to ${preFile}`);
|
|
1400
|
+
}
|
|
1401
|
+
|
|
1402
|
+
const approvals = [{ specHash, signer: env.USER ?? 'human', ts: new Date().toISOString() }];
|
|
1403
|
+
// spec.baseUrl (PRD item 28, ruling (d), 2026-09-09: admitted in v1) — an
|
|
1404
|
+
// OpenAI-compatible gateway/endpoint. Undefined for anthropic-api jobs and
|
|
1405
|
+
// for an openai-api job that names none (bare-agent's OpenAIProvider then
|
|
1406
|
+
// defaults to api.openai.com/v1 on its own).
|
|
1407
|
+
const baseUrl = typeof spec.baseUrl === 'string' ? spec.baseUrl : undefined;
|
|
1408
|
+
// TEST SEAM — same shape as src/cli.js's `doRun`: a caller that supplies
|
|
1409
|
+
// `deps.provider` skips the real-key check above entirely and this
|
|
1410
|
+
// construction never runs (a scripted provider IS the run).
|
|
1411
|
+
let provider = deps.provider;
|
|
1412
|
+
let providerFor = deps.providerFor;
|
|
1413
|
+
let judgeProvider = deps.judgeProvider ?? null;
|
|
1414
|
+
if (!provider) {
|
|
1415
|
+
provider = makeProvider(spec.provider, { apiKey: /** @type {string} */ (workerApiKey), model: MODEL, baseUrl });
|
|
1416
|
+
// P: the per-step model-tier factory. The TIER menu a PLAN may name is signed in the
|
|
1417
|
+
// plan schema (STEP_MODELS — sonnet-only since the 2026-08-06 haiku attribution probe);
|
|
1418
|
+
// the tier->model mapping is the RUNNER's territory, here, and keeps haiku for the
|
|
1419
|
+
// operator's own --model knob. haiku takes no output_config.effort (provider-gated,
|
|
1420
|
+
// battery rule) - nothing to gate yet since neither tier sets effort params.
|
|
1421
|
+
// `sonnet` maps to the RESOLVED worker model (MODEL — spec/flag/default), not the
|
|
1422
|
+
// bare provider-table id, so a spec-named model reaches every plan step's own
|
|
1423
|
+
// provider (STEP_MODELS is sonnet-only), not just the top-level `provider` above.
|
|
1424
|
+
// ONLY when the SPEC named it: a `--model haiku` probe keeps its old reach (the
|
|
1425
|
+
// top-level provider), never silently widening into every step's tier. The tier
|
|
1426
|
+
// table itself comes from `providerEntry` (src/providers.js) — for openai-api
|
|
1427
|
+
// today that means BOTH tiers resolve to the same `deepseek-flash` id (hamr's
|
|
1428
|
+
// ruling, PRD 30.7: one secondary provider, not a menu; swapped from the
|
|
1429
|
+
// retired `deepseek-chat`, F171), so a `--model haiku` probe against an
|
|
1430
|
+
// openai-api job harmlessly re-resolves to the same model.
|
|
1431
|
+
const TIER_MODELS = modelResolution.source === 'spec' ? { ...providerEntry.tiers, sonnet: MODEL } : providerEntry.tiers;
|
|
1432
|
+
/** @type {Record<string, any>} */
|
|
1433
|
+
const tierCache = {};
|
|
1434
|
+
providerFor = (/** @type {string} */ tier) => (tierCache[tier] ??= TIER_MODELS[/** @type {keyof typeof TIER_MODELS} */ (tier)] === MODEL ? provider : makeProvider(spec.provider, { apiKey: /** @type {string} */ (workerApiKey), model: TIER_MODELS[/** @type {keyof typeof TIER_MODELS} */ (tier)], baseUrl }));
|
|
1435
|
+
// SOFTGREEN — the JUDGED stage's own provider. Since PRD item 32.1 its identity is
|
|
1436
|
+
// RESOLVED (`judge` above), not pinned: the spec's signed `judge` override, else this
|
|
1437
|
+
// job's own worker model. It is still never a step knob and never agent-selectable —
|
|
1438
|
+
// naming your own examiner is arbiter territory, and `judge` is a field the HUMAN
|
|
1439
|
+
// signs into the hash. The `baseUrl` rides along only when the judge is the same
|
|
1440
|
+
// provider as the worker: a spec's `baseUrl` is the WORKER's endpoint, and pointing a
|
|
1441
|
+
// different vendor's client at it would be the silent-misconfiguration class
|
|
1442
|
+
// `endpointKey` exists to prevent. Built only when `judgeApiKey` is present (PRD item
|
|
1443
|
+
// 31.4 — the judge's key is demanded only when a judge is CALLED, so this construction
|
|
1444
|
+
// is conditional on purpose, not unconditional): `runPlan` reads it only when a stage
|
|
1445
|
+
// is `judged-floor`, and the alternative (deriving "does this close judge?" here) is a
|
|
1446
|
+
// second reading of the declaration that can drift from the one the runner actually
|
|
1447
|
+
// executes. Absent, a judged stage instrument-STOPS as a wiring gap, which is what
|
|
1448
|
+
// every live softgreen run would have done: run-author wired this seam and this
|
|
1449
|
+
// runner never did.
|
|
1450
|
+
judgeProvider = judgeApiKey
|
|
1451
|
+
? makeProvider(judge.provider, { apiKey: /** @type {string} */ (judgeApiKey), model: judge.model, baseUrl: judge.provider === spec.provider ? baseUrl : undefined })
|
|
1452
|
+
: null;
|
|
1453
|
+
}
|
|
1454
|
+
|
|
1455
|
+
const started = Date.now();
|
|
1456
|
+
out(`\n== U run ${runid} == $${spec.budgetUsd} · ${WALL_LABEL} · ${MODEL_LABEL}`);
|
|
1457
|
+
// the arm is named on the run's own stdout: a battery row whose arm is only in the
|
|
1458
|
+
// driver's plan is a row nobody can audit from its own log.
|
|
1459
|
+
out(` ${READ_SHIM_LABEL}`);
|
|
1460
|
+
out(` ${SCOUT_LABEL}`);
|
|
1461
|
+
|
|
1462
|
+
// F67 — the OUTSIDE watchdog, started before the run and sharing nothing with it.
|
|
1463
|
+
// Every guard bareloop had lived inside this process, and ms3197n8/ms3jh76q proved
|
|
1464
|
+
// that is exactly where they cannot help: 274min and 81.5min of total silence, the
|
|
1465
|
+
// in-process fuse never running because whatever froze the run froze its timers
|
|
1466
|
+
// too. This one is a separate process holding one file's mtime and a pid.
|
|
1467
|
+
// `unref()` so it can never keep a finished run alive.
|
|
1468
|
+
// The STALE window is sized above the worst LEGAL spine silence, which is the
|
|
1469
|
+
// CLOSE, not the worker: runStages emits nothing between stages (the close
|
|
1470
|
+
// runner is async since the F68 fix, but its spine silence is unchanged), so a
|
|
1471
|
+
// legal close can be
|
|
1472
|
+
// silent for up to closeTimeoutMs × stages. The default 600s window against a
|
|
1473
|
+
// 900s-per-stage cap would kill a live verdict mid-close — the exact damage the
|
|
1474
|
+
// wall grace exists to prevent, on the trigger the grace never covered.
|
|
1475
|
+
// The WALL's grace is the SAME arithmetic, and it must be passed: the watchdog's
|
|
1476
|
+
// own default is one stage (900s), so a 4-stage close left on the default put the
|
|
1477
|
+
// outside deadline at wall+15min while a legal close can still be mid-verdict at
|
|
1478
|
+
// wall+60min — the guard could destroy a live verdict. This is the one number that
|
|
1479
|
+
// keeps the outside deadline equal to the deadline the run's own clock enforces
|
|
1480
|
+
// (src/clock.js: maxWallMs + closeStages × closeTimeoutMs).
|
|
1481
|
+
// THE COUNT COMES FROM `closeStagesOf`, the runner's own derivation (src/plan.js),
|
|
1482
|
+
// because the guard and the thing it guards must read the SAME number. Reading
|
|
1483
|
+
// `spec.close` alone counted an AUTHORED close (`closeDecl`) as one stage — a
|
|
1484
|
+
// six-stage declaration would have armed both windows at a sixth of the silence a
|
|
1485
|
+
// legal close may take, and the outside guard would SIGKILL a live verdict
|
|
1486
|
+
// mid-close. That is F70's shape exactly: the instrument built to guard a failure
|
|
1487
|
+
// mode carrying that failure mode. A legacy object-form close (the locked verdict
|
|
1488
|
+
// classes, job.js) is still ONE stage — `stageClose` wraps it — and the floor
|
|
1489
|
+
// below is live rather than decorative: this spec is read raw off disk and is not
|
|
1490
|
+
// validateJob'd until runJob, so `closeStagesOf` can return null (a spec naming no
|
|
1491
|
+
// close at all) or an empty list, and a 0 or NaN here would disarm both windows
|
|
1492
|
+
// silently.
|
|
1493
|
+
const closeStages = closeStagesOf(spec)?.length || 1;
|
|
1494
|
+
// PRD item 27/M3 — the watchdog needs the REAL per-stage ceiling before it can
|
|
1495
|
+
// size its own windows, and this run's own clock/close calls need the exact
|
|
1496
|
+
// same number, so it is resolved ONCE, here, through the same function
|
|
1497
|
+
// `src/planrun.js` uses in-run (`resolveCloseTimeoutMs`), before the watchdog
|
|
1498
|
+
// spawn or the run itself sees a single provider token. `emit` is created
|
|
1499
|
+
// here (not inline at the `runJob` call below) so this pre-run reading lands
|
|
1500
|
+
// on the run's own spine in the SAME seq order everything else does, rather
|
|
1501
|
+
// than a second, disconnected spine writer.
|
|
1502
|
+
const emit = makeSpine(spineFile);
|
|
1503
|
+
|
|
1504
|
+
// PRD item 33/M2, call site 3(a) — a source-door manifest, when this patient
|
|
1505
|
+
// was PREPARED through `prepareSource` (`scripts/prep-source.mjs`), sits
|
|
1506
|
+
// beside its tree at `<into>/source.json`; `wd` IS `<into>/tree`, so its own
|
|
1507
|
+
// parent is `<into>`. A patient the JOBS table above points at directly
|
|
1508
|
+
// (every row today) never went through the front door and carries no
|
|
1509
|
+
// manifest — that absence IS "repo jobs untouched", never an invented
|
|
1510
|
+
// destination for a job that declared none. A manifest that EXISTS but
|
|
1511
|
+
// cannot be read or parsed is a named $0 stop, never a silent skip.
|
|
1512
|
+
const sourceManifest = await readSourceManifest(dirname(wd));
|
|
1513
|
+
if (sourceManifest.stop !== null) {
|
|
1514
|
+
emit('destination-refused', { code: sourceManifest.code, detail: sourceManifest.stop });
|
|
1515
|
+
emit('run-end', { outcome: 'escalated' });
|
|
1516
|
+
err(`SOURCE-MANIFEST-RED — ${sourceManifest.stop}. See ${spineFile}`);
|
|
1517
|
+
throw new ExitSignal(1);
|
|
1518
|
+
}
|
|
1519
|
+
/** the front door's per-run instance, or null when there is none (no
|
|
1520
|
+
* manifest, or a manifest that never declared a destination) — read ONCE,
|
|
1521
|
+
* here, and reused at the copy-out call site below rather than re-read. */
|
|
1522
|
+
const frontDoor = frontDoorFromManifest(sourceManifest);
|
|
1523
|
+
if (frontDoor) {
|
|
1524
|
+
// BEFORE ANY TOKEN (hamr's ruling, PRD item 33): a job that cannot land its
|
|
1525
|
+
// output should never buy a worker turn first. `into: dirname(wd)` — review
|
|
1526
|
+
// finding #3 — NOT `wd`: `wd` IS `<into>/tree`, so proving containment
|
|
1527
|
+
// against `wd` alone let a destination at `<into>/profile.md` (inside the
|
|
1528
|
+
// run's own scratch area, outside the frozen tree, but still bareloop's
|
|
1529
|
+
// own scratch plumbing no destination should land in) slip through clean.
|
|
1530
|
+
const dp = await proveDestination(frontDoor.destination, { into: dirname(wd) });
|
|
1531
|
+
if (dp.stop !== null) {
|
|
1532
|
+
emit('destination-refused', { code: dp.code, detail: dp.stop });
|
|
1533
|
+
emit('run-end', { outcome: 'escalated' });
|
|
1534
|
+
err(`DESTINATION-RED — ${dp.stop}. See ${spineFile}`);
|
|
1535
|
+
throw new ExitSignal(1);
|
|
1536
|
+
}
|
|
1537
|
+
}
|
|
1538
|
+
|
|
1539
|
+
const closeTimingResolved = await resolveCloseTimeoutMs({ job: spec, stages: closeStagesOf(spec) ?? [], cwd: wd, redact: redactSecrets });
|
|
1540
|
+
if (closeTimingResolved.timedOut) {
|
|
1541
|
+
// Same invariant as the door's own timing check above: `resolveCloseTimeoutMs`
|
|
1542
|
+
// only omits `timing` on the signed-override path, which is never `timedOut`.
|
|
1543
|
+
if (closeTimingResolved.timing === null) throw new Error('unreachable: resolveCloseTimeoutMs sets timing whenever timedOut is true');
|
|
1544
|
+
const names = closeTimingResolved.timing.perStage.filter((s) => s.timedOut).map((s) => s.name).join(', ');
|
|
1545
|
+
emit('close-timing', { perStage: closeTimingResolved.timing.perStage, slowestMs: closeTimingResolved.timing.slowestMs, slowestName: closeTimingResolved.timing.slowestName, ceilingMs: null, source: 'estimated' });
|
|
1546
|
+
emit('escalation', {
|
|
1547
|
+
category: 'close-timing-red', decisionReady: true,
|
|
1548
|
+
decision: `A close stage did not finish within the timing preflight's own ceiling — the close cannot run on this machine in a boundable time, so nothing was run and nothing was spent. Stage(s): ${names}`,
|
|
1549
|
+
options: ['investigate why the stage hangs (infra/network/resource issue)', 'sign an explicit closeTimeoutMs override once the real duration is known', 'abandon the task'],
|
|
1550
|
+
});
|
|
1551
|
+
emit('run-end', { outcome: 'escalated' });
|
|
1552
|
+
err(`CLOSE-TIMING-RED — stage(s) never finished the timing preflight: ${names}. See ${spineFile}`);
|
|
1553
|
+
throw new ExitSignal(1);
|
|
1554
|
+
}
|
|
1555
|
+
const RESOLVED_CLOSE_TIMEOUT_MS = /** @type {number} */ (closeTimingResolved.closeTimeoutMs);
|
|
1556
|
+
// F133 (run mtqwmb9l) — this reading is used ONLY to size the outside
|
|
1557
|
+
// watchdog below (`worstCloseSilenceMs`); it is NEVER announced (no
|
|
1558
|
+
// `emit('close-timing', …)`, no banner print) and NEVER passed into `runJob`
|
|
1559
|
+
// (see the F133 comment at that call site). The pre-fix shape printed AND
|
|
1560
|
+
// emitted this exact reading here, then fed it into `runJob` as a bare
|
|
1561
|
+
// `closeTimeoutMs`, which printed/emitted it a SECOND time under the wrong
|
|
1562
|
+
// word ("explicit runner override") — two banner lines for one number, the
|
|
1563
|
+
// second one lying about its own source. `runJob` now runs its own timing
|
|
1564
|
+
// pass (autoset, or the spec's own signed field) and is the ONE place that
|
|
1565
|
+
// announces the ceiling for this run — a small, deliberate, $0 re-measure
|
|
1566
|
+
// (never two AI-worker rounds, only close scripts) traded for one honest,
|
|
1567
|
+
// correctly-labelled line instead of two.
|
|
1568
|
+
const worstCloseSilenceMs = RESOLVED_CLOSE_TIMEOUT_MS * closeStages;
|
|
1569
|
+
// `fileURLToPath`, not `.pathname`: a URL keeps its path percent-ENCODED, so a repo
|
|
1570
|
+
// checked out under a directory with a space (or any of `#?%`) hands spawn a path
|
|
1571
|
+
// containing `%20` that does not exist, and the guard dies at startup on the one run
|
|
1572
|
+
// it was meant to protect. Same spelling the sibling scripts use.
|
|
1573
|
+
const watchdog = spawn(process.execPath, [
|
|
1574
|
+
// PANEL-BUILD.md P0 — this engine now lives in src/, one directory over
|
|
1575
|
+
// from scripts/u-watchdog.mjs (it used to be a same-directory sibling).
|
|
1576
|
+
fileURLToPath(new URL('../scripts/u-watchdog.mjs', import.meta.url)),
|
|
1577
|
+
'--spine', spineFile,
|
|
1578
|
+
'--pid', String(process.pid),
|
|
1579
|
+
'--stale-ms', String(worstCloseSilenceMs + 600_000),
|
|
1580
|
+
// omitted entirely on an unbounded spec (see WALL_MS above): the watchdog's own
|
|
1581
|
+
// startup line then says "no wall", so the choice is loud on BOTH sides of the
|
|
1582
|
+
// process boundary and the STALE trigger — which needs no wall — stays armed.
|
|
1583
|
+
// On a RESUME this is the REMAINDER, not the signed total: the run's own clock
|
|
1584
|
+
// starts at `priorWallMs`, so arming the outside guard for the whole wall again
|
|
1585
|
+
// would put the two deadlines minutes apart and leave the guard unable to fire in
|
|
1586
|
+
// time on exactly the run it is watching (F70's shape).
|
|
1587
|
+
...(RESUME_WALL_MS === null ? [] : ['--wall-ms', String(RESUME_WALL_MS)]),
|
|
1588
|
+
'--grace-ms', String(worstCloseSilenceMs),
|
|
1589
|
+
], { stdio: ['ignore', 'ignore', 'inherit'] });
|
|
1590
|
+
// A spawn failure arrives as an EVENT, and an unhandled 'error' on a ChildProcess is
|
|
1591
|
+
// an uncaught exception — i.e. a guard that could not start would END the paid run it
|
|
1592
|
+
// exists to protect, at second zero. The run must continue UNGUARDED and say so:
|
|
1593
|
+
// losing the outside guard is a degraded run (the in-process fuses and the money cap
|
|
1594
|
+
// still bound it), losing the run is a wasted budget. Loud, because a reader must
|
|
1595
|
+
// never later mistake a missing watchdog marker for "the guard was watching and never
|
|
1596
|
+
// fired".
|
|
1597
|
+
watchdog.on('error', (e) => {
|
|
1598
|
+
err(`\nWATCHDOG FAILED TO START (${e.message}) — this run is UNGUARDED from outside: a frozen event loop will NOT be reaped (F67). The run continues under its own fuses, wall clock and money cap.`);
|
|
1599
|
+
});
|
|
1600
|
+
watchdog.unref();
|
|
1601
|
+
|
|
1602
|
+
// WHERE-was-the-freeze sampler. ms3jh76q froze its event loop for 81.5min and the
|
|
1603
|
+
// spine could not say where — a frozen loop emits nothing, by construction. This
|
|
1604
|
+
// timer is delayed exactly as long as any block; when it finally fires it records
|
|
1605
|
+
// how late it ran, which brackets the freeze in time. Diagnostic sidecar only:
|
|
1606
|
+
// nothing in the run reads it, and it decides nothing (the arbiter is untouched).
|
|
1607
|
+
// Coverage split with the watchdog: blocks that END show up here; a block long
|
|
1608
|
+
// enough for the watchdog's stale kill dies mid-freeze and is localized by the
|
|
1609
|
+
// marker + the last spine event instead. Both windows are covered, by different
|
|
1610
|
+
// instruments.
|
|
1611
|
+
const LAG_POLL_MS = 1_000;
|
|
1612
|
+
const LAG_RECORD_MS = 3_000; // healthy gaps measured in ms; litectx's sync index blocked ~18s
|
|
1613
|
+
const lagFile = `${spineFile}.lag.jsonl`;
|
|
1614
|
+
let lagDue = Date.now() + LAG_POLL_MS;
|
|
1615
|
+
const lagTimer = setInterval(() => {
|
|
1616
|
+
const now = Date.now();
|
|
1617
|
+
const blockedMs = now - lagDue;
|
|
1618
|
+
if (blockedMs >= LAG_RECORD_MS) {
|
|
1619
|
+
const rec = { blockedMs, from: new Date(now - blockedMs).toISOString(), until: new Date(now).toISOString() };
|
|
1620
|
+
try { appendFileSync(lagFile, `${JSON.stringify(rec)}\n`); } catch { /* best-effort — a diagnostic must never kill the run */ }
|
|
1621
|
+
}
|
|
1622
|
+
lagDue = now + LAG_POLL_MS;
|
|
1623
|
+
}, LAG_POLL_MS);
|
|
1624
|
+
|
|
1625
|
+
let outcome;
|
|
1626
|
+
try {
|
|
1627
|
+
outcome = await runJob(spec, {
|
|
1628
|
+
// PRD item 27/M3 — the SAME `emit` the pre-run close-timing reading above
|
|
1629
|
+
// used (never a second `makeSpine(spineFile)` here: two independent
|
|
1630
|
+
// emitters against one file would both start their seq counter at 0 and
|
|
1631
|
+
// collide the moment either one had already written a record).
|
|
1632
|
+
approvals, workdir: wd, provider, providerFor, judgeProvider, judgeModel: judge.model, emit,
|
|
1633
|
+
// F133 (run mtqwmb9l) — `closeTimeoutMs` is deliberately NOT passed here.
|
|
1634
|
+
// `RESOLVED_CLOSE_TIMEOUT_MS` above exists only to size the outside
|
|
1635
|
+
// watchdog before it spawns; feeding it back into `runJob` used to make
|
|
1636
|
+
// this call print a SECOND, mislabelled "explicit runner override" line
|
|
1637
|
+
// on top of run-u's own already-printed banner (removed above, same
|
|
1638
|
+
// reason). `runJob` resolves (autoset, or the spec's own signed field)
|
|
1639
|
+
// and announces the ceiling itself now — the ONE banner/spine record
|
|
1640
|
+
// for this run.
|
|
1641
|
+
shellCapUsd: spec.budgetUsd, capRuns: CAP_RUNS, strikeLimit: STRIKE_LIMIT,
|
|
1642
|
+
closeDir: spineDir,
|
|
1643
|
+
readShim: READ_SHIM,
|
|
1644
|
+
scout: SCOUT,
|
|
1645
|
+
// RESUME: the money and the wall the halted run already burned are FOLDED IN (so
|
|
1646
|
+
// the signed ceiling cannot widen by being re-invoked), and the checkpoint it
|
|
1647
|
+
// reached is handed over so the plan is reloaded rather than re-drafted and the
|
|
1648
|
+
// finished steps are skipped rather than re-paid.
|
|
1649
|
+
...(dead ? {
|
|
1650
|
+
priorSpentUsd: dead.restart.priorSpentUsd,
|
|
1651
|
+
// and whether that fold was EXACT — a floor stays a floor across the resume (F6)
|
|
1652
|
+
priorSpendComplete: dead.restart.priorSpendComplete,
|
|
1653
|
+
priorWallMs: dead.restart.priorWallMs,
|
|
1654
|
+
...(dead.restart.seed ? { resumeSeed: dead.restart.seed } : {}),
|
|
1655
|
+
// and the dead leg's own close GRADES, so this leg's halt readouts judge the
|
|
1656
|
+
// whole chain rather than the leg. A leg that re-grades an unchanged tree is
|
|
1657
|
+
// flat on its own evidence; the RUN — which is what the top-up decision is
|
|
1658
|
+
// about — may have been converging when the allowance ran out.
|
|
1659
|
+
...(dead.restart.grades?.length ? { resumeGrades: dead.restart.grades } : {}),
|
|
1660
|
+
// and the REPLAN ledger the chain has spent. The line above feeds a readout; this
|
|
1661
|
+
// one feeds a BOUND — `replanned`/`varianceGrantUsed` are locals in `runPlan`, so
|
|
1662
|
+
// an unforwarded ledger hands this leg a fresh allowance of one PRD v1.12 makes the
|
|
1663
|
+
// RUN's, once per kill. It rides onto this leg's `job-start` as `priorReplans` too,
|
|
1664
|
+
// which is what lets a resume OF a resume fold once instead of restarting the
|
|
1665
|
+
// ceiling at whichever window it happens to read.
|
|
1666
|
+
...(dead.restart.replans > 0
|
|
1667
|
+
? { resumeReplans: { count: dead.restart.replans, grantUsed: dead.restart.replanGrantUsed } }
|
|
1668
|
+
: {}),
|
|
1669
|
+
// and WHERE the dead leg's work is sitting (PRD v1.57 §3). The branch name is
|
|
1670
|
+
// derived from the SIGNED spec, so it is identical on every leg: without this the
|
|
1671
|
+
// restart collides with its own predecessor's branch and the collision walk hands
|
|
1672
|
+
// it a fresh `-2` standing beside the work it came back to continue.
|
|
1673
|
+
...(dead.restart.branch ? { resumeBranch: dead.restart.branch } : {}),
|
|
1674
|
+
} : {}),
|
|
1675
|
+
// N4 — the SIGNER's answer, normalised at the library's own seam above and
|
|
1676
|
+
// carried in on the leg that resumes the pause. It is spent ONCE, on this leg's
|
|
1677
|
+
// close readings: `accept` greens the human stage after the mechanical stages
|
|
1678
|
+
// re-run on the tree as it stands (OPEN-3), `rerun` reds it with these words as
|
|
1679
|
+
// the gap, and `pause` never gets this far (it is answered above, launching nothing).
|
|
1680
|
+
// Absent on every ordinary run — refused-but-unwired would pause forever.
|
|
1681
|
+
// …and NOT on the review-door path (module 8), where the same `--decide rerun`
|
|
1682
|
+
// means something else entirely: a `humanRuling` answers a close's human STAGE
|
|
1683
|
+
// and is refused for a close that has none, which is every green-class job. The
|
|
1684
|
+
// door's rerun travels as `doorRerun` below instead. One flag, two questions —
|
|
1685
|
+
// the runner picks by which run is being answered, never by the flag's spelling.
|
|
1686
|
+
...(RULING === null || DOOR !== null ? {} : { humanRuling: RULING }),
|
|
1687
|
+
// F102 — and the answer the CHECKPOINT holds, when the leg before this one died
|
|
1688
|
+
// holding it. Same seam, same semantics, one difference the spine records: the
|
|
1689
|
+
// decision came from a record rather than from a person on this invocation. The
|
|
1690
|
+
// two are never both set (the resolver refused above), so this can never be a
|
|
1691
|
+
// silent override of words somebody just typed.
|
|
1692
|
+
...(HELD === null ? {} : { heldRuling: HELD }),
|
|
1693
|
+
// MODULE 8 — does this run END at a review door? Soft-green opens one unasked
|
|
1694
|
+
// (the library's class default); `--review-door` is the green-class opt-in; and a
|
|
1695
|
+
// leg that IS a rerun taken at a door opens the next one by construction — the
|
|
1696
|
+
// person is working through the door, and a rerun that ended silently would be
|
|
1697
|
+
// the one leg in the chain they could not answer.
|
|
1698
|
+
...(REVIEW_DOOR_ON || DOOR_RERUN ? { reviewDoor: true } : {}),
|
|
1699
|
+
// …and the signer's words, when this leg is that rerun. The money the answered
|
|
1700
|
+
// run spent folds in (the signed budget is the chain's ceiling); the WALL does
|
|
1701
|
+
// not (F103 — a rerun that inherits a dead leg's clock is structurally doomed).
|
|
1702
|
+
...(DOOR_RERUN === null ? {} : {
|
|
1703
|
+
doorRerun: DOOR_RERUN,
|
|
1704
|
+
priorSpentUsd: doorPrior?.spentUsd ?? 0,
|
|
1705
|
+
priorSpendComplete: doorPrior?.spendComplete !== false,
|
|
1706
|
+
}),
|
|
1707
|
+
});
|
|
1708
|
+
} finally {
|
|
1709
|
+
// the guard outlives the run only by accident, never by design
|
|
1710
|
+
try { watchdog.kill('SIGKILL'); } catch { /* already gone */ }
|
|
1711
|
+
clearInterval(lagTimer);
|
|
1712
|
+
}
|
|
1713
|
+
const legMs = Date.now() - started;
|
|
1714
|
+
|
|
1715
|
+
// ── the read. Facts only: what happened, what it cost, and whether the record is
|
|
1716
|
+
// honest. No classification into pass/fail buckets — one run classifies nothing.
|
|
1717
|
+
const raw = readFileSync(spineFile, 'utf8');
|
|
1718
|
+
const events = raw.trimEnd().split('\n').filter(Boolean).map((l) => JSON.parse(l));
|
|
1719
|
+
const je = events.findLast((e) => e.type === 'job-end');
|
|
1720
|
+
// the ONE spelling of the text-side scan (src/validate.js) — a hand-rolled copy
|
|
1721
|
+
// here would be the ninth, and one that misses a shape leaks on the very output
|
|
1722
|
+
// it guards. Only the COUNT is ever read out; the matches themselves stay here.
|
|
1723
|
+
const leaks = scanSecrets(raw);
|
|
1724
|
+
// the ACCEPTED plan (a replan emits its own) — plan-validate carries verdicts, not the plan
|
|
1725
|
+
const plan = events.findLast((e) => e.type === 'plan-accepted')?.plan ?? null;
|
|
1726
|
+
|
|
1727
|
+
const auditSrc = join(wd, 'gate-audit.jsonl');
|
|
1728
|
+
let auditFile = null;
|
|
1729
|
+
if (existsSync(auditSrc)) { auditFile = join(spineDir, `u-${runid}-gate-audit.jsonl`); renameSync(auditSrc, auditFile); }
|
|
1730
|
+
const audit = auditFile ? readFileSync(auditFile, 'utf8').trimEnd().split('\n').filter(Boolean).map((l) => JSON.parse(l)) : [];
|
|
1731
|
+
const writes = audit.filter((e) => e.decision === 'allow' && (e.action?.type === 'write' || e.action?.type === 'edit'));
|
|
1732
|
+
|
|
1733
|
+
out(`\noutcome ${outcome}`);
|
|
1734
|
+
out(`spent ${je?.spentUsd == null ? 'UNKNOWN' : `${je.spendComplete === false ? '≥' : ''}$${je.spentUsd.toFixed(4)}`} of $${spec.budgetUsd}`);
|
|
1735
|
+
// FOLDED, exactly like the money line above it: on a resume the cap governs both legs
|
|
1736
|
+
// together, and a leg-only wall next to a folded spend is two framings on one cap with
|
|
1737
|
+
// no label to tell them apart (F83). The leg stays on the line beside it.
|
|
1738
|
+
out(`wall ${wallLine({ legMs, priorWallMs: dead ? dead.restart.priorWallMs : 0, wallLabel: WALL_LABEL })}`);
|
|
1739
|
+
const legRounds = events.filter((e) => e.type === 'worker-round' && e.kind === 'turn').length;
|
|
1740
|
+
out(`rounds ${legRounds}`);
|
|
1741
|
+
// THIS LEG's own tokens, off THIS LEG's own spine — same scope as `rounds` above it,
|
|
1742
|
+
// never the folded chain (see `tokensLine`'s doc for why worker-result is excluded).
|
|
1743
|
+
out(`tokens ${tokensLine({ events })}`);
|
|
1744
|
+
out(`writes ${writes.length} allowed (${new Set(writes.map((e) => e.action?.path)).size} distinct files)`);
|
|
1745
|
+
out(`plan ${plan ? `${plan.steps?.length ?? '?'} ${plan.steps?.length === 1 ? 'step' : 'steps'}` : 'none validated'}`);
|
|
1746
|
+
out(`checks ${events.filter((e) => e.type === 'check-run').length} runs · menu [${events.find((e) => e.type === 'check-menu')?.offered?.join(', ') ?? '-'}]`);
|
|
1747
|
+
// the close as JUDGED FIRST, then whether the fix loop had to run. Printing only
|
|
1748
|
+
// the last outer-close reads as "close red" next to "outcome green" — the record
|
|
1749
|
+
// must not make the reader reconcile two numbers that mean different things.
|
|
1750
|
+
const oc = events.findLast((e) => e.type === 'outer-close');
|
|
1751
|
+
const fixed = events.some((e) => e.type === 'fix-loop');
|
|
1752
|
+
out(`close first judgment ${oc?.verdict ?? '-'}${oc?.stage ? ` (stage ${oc.stage})` : ''}${fixed ? ` → fix loop ran → ${outcome}` : ''}`);
|
|
1753
|
+
out(`replan ${events.some((e) => e.type === 'replan') ? 'YES' : 'no'}`);
|
|
1754
|
+
// F115 — absence, never a fabricated 0: printed ONLY when a transport retry
|
|
1755
|
+
// actually happened, so a run with none says nothing about retries at all.
|
|
1756
|
+
const transportRetries = events.filter((e) => e.type === 'transport-retry');
|
|
1757
|
+
if (transportRetries.length > 0) {
|
|
1758
|
+
const recovered = transportRetries.some((e) => e.recovered === true);
|
|
1759
|
+
out(`retries ${transportRetries.length} transport retr${transportRetries.length === 1 ? 'y' : 'ies'} · ${recovered ? 'recovered' : 'not recovered'} (spend is a floor)`);
|
|
1760
|
+
}
|
|
1761
|
+
for (const e of events.filter((x) => x.type === 'escalation')) out(`ESCALATION ${e.category}: ${(e.decision ?? '').slice(0, 160)}`);
|
|
1762
|
+
// v1.46 §2 — the MONEY halt reads out like the wall's: the verdict that STANDS, the
|
|
1763
|
+
// per-stage trend that says which lever fits, and the three levers themselves. The
|
|
1764
|
+
// run pauses decision-ready; nothing here adjusts a budget, and nothing here relaunches.
|
|
1765
|
+
const mh = events.findLast((e) => e.type === 'money-halt');
|
|
1766
|
+
if (mh) {
|
|
1767
|
+
out(`\nMONEY HALT — the cap cut the run at $${mh.remainingUsd?.toFixed?.(4) ?? '?'} left of $${mh.budgetUsd}. The verdict already minted STANDS: ${mh.verdict ?? 'unknown'}${mh.stage ? ` at stage "${mh.stage}"` : ''}.`);
|
|
1768
|
+
out(` trend ${mh.trend} — ${mh.reading}`);
|
|
1769
|
+
out(` lever ${mh.lever}`);
|
|
1770
|
+
for (const o of mh.options ?? []) out(` · ${o}`);
|
|
1771
|
+
out(` resume node scripts/run-u.mjs ${SELECTOR} --resume ${runid}${SHIM_TAIL}${SCOUT_TAIL} --approve <the NEW hash after you edit budgetUsd>`);
|
|
1772
|
+
out(' (the top-up is yours to sign — nothing in the run may widen its own budget)');
|
|
1773
|
+
}
|
|
1774
|
+
// A STALL is a checkpoint too (hamr's go, 2026-08-13). Its own escalation prints one
|
|
1775
|
+
// line above and says *"retry the run"*, and until this line the only retry on offer
|
|
1776
|
+
// was a COLD one that re-drafts the plan and re-pays for every step already finished.
|
|
1777
|
+
// Unlike the money halt above, nothing here needs re-signing: no allowance moved, so
|
|
1778
|
+
// the hash already approved is the hash that resumes.
|
|
1779
|
+
if (outcome === 'step-stalled') {
|
|
1780
|
+
out('\nSTALL HALT — the model stopped producing rounds and reissuing the call did not recover it. The tree, the plan and the steps already finished STAND.');
|
|
1781
|
+
out(` resume node scripts/run-u.mjs ${SELECTOR} --resume ${runid}${SHIM_TAIL}${SCOUT_TAIL} --approve ${specHash}`);
|
|
1782
|
+
out(' (no spec edit, so the hash is unchanged — this re-enters at the stalled step and re-pays for none of the ones before it)');
|
|
1783
|
+
out(' (if the allowance is what actually ran out underneath the stall, that preview says so and refuses — it is read there, not asserted here)');
|
|
1784
|
+
}
|
|
1785
|
+
// PRD v1.80 TODO #4 / F115 — provider-red JOINS the resumable set. The one
|
|
1786
|
+
// transport retry (src/planrun.js's `withTransportRetry`) already ran and
|
|
1787
|
+
// still failed, so `spent` above is already a FLOOR (`je.spendComplete ===
|
|
1788
|
+
// false` from src/run.js's F44 branch) — never rounded up to exact. What is
|
|
1789
|
+
// added here is WHERE the death landed, read back off this run's own spine
|
|
1790
|
+
// through the SAME reader `--resume` uses (never a second one), and the
|
|
1791
|
+
// exact, copy-pasteable command. No cost/step threshold decides anything
|
|
1792
|
+
// here — that decision is hamr's, case by case, off this readout.
|
|
1793
|
+
if (outcome === 'provider-red') {
|
|
1794
|
+
const pr = readResume(events, { direct: true, resumableOutcomes: RESUMABLE_HALTS });
|
|
1795
|
+
const seed = pr.restart?.seed ?? null;
|
|
1796
|
+
const total = Array.isArray(seed?.plan?.steps) ? seed.plan.steps.length : null;
|
|
1797
|
+
const done = Array.isArray(seed?.completedSteps) ? seed.completedSteps.length : 0;
|
|
1798
|
+
out('\nPROVIDER-RED — the transport retry above also failed; no close ever rendered a verdict. The tree, the plan and the steps already finished STAND.');
|
|
1799
|
+
out(` died ${total === null ? 'before a plan was accepted — nothing paid is re-payable'
|
|
1800
|
+
: done >= total ? `at the close — all ${total} step(s) finished`
|
|
1801
|
+
: `in step ${done + 1} of ${total}`}`);
|
|
1802
|
+
out(` resume node scripts/run-u.mjs ${SELECTOR} --resume ${runid}${SHIM_TAIL}${SCOUT_TAIL} --approve ${specHash}`);
|
|
1803
|
+
out(' (no spec edit, so the hash is unchanged — this re-enters at the recorded step and re-pays for none of the ones before it)');
|
|
1804
|
+
out(' (if the allowance is what actually ran out underneath the transport fault, that preview says so and refuses — it is read there, not asserted here)');
|
|
1805
|
+
}
|
|
1806
|
+
// ── N4 §1.4/§5.2 — THE PAUSE, at the terminal the person is actually standing at.
|
|
1807
|
+
// The run stopped decision-ready with the clock stopped (ruling 1) and everything
|
|
1808
|
+
// it did stands until an answer comes back. This renders the SAME evidence package
|
|
1809
|
+
// the resume preview renders, from the SAME record and the SAME function: one
|
|
1810
|
+
// assembly of one run's facts, two screens.
|
|
1811
|
+
//
|
|
1812
|
+
// A decision moves no allowance, so nothing here needs re-signing — the hash that
|
|
1813
|
+
// bought this run is the hash that answers it, exactly like the stall readout above.
|
|
1814
|
+
if (outcome === HITL_PAUSE) {
|
|
1815
|
+
const pause = events.filter((e) => e.type === HITL_PAUSE).at(-1) ?? null;
|
|
1816
|
+
out('');
|
|
1817
|
+
printPauseEvidence(pause, [
|
|
1818
|
+
'HUMAN REVIEW — the run paused for a person but wrote no `hitl-pause` record; read the spine and the patient',
|
|
1819
|
+
'yourself before answering. Nothing here can stand in for the evidence it did not write.',
|
|
1820
|
+
]);
|
|
1821
|
+
out(' clock STOPPED — the wall does not run while a person is reading (W-2), and this leg\'s elapsed is what folds into the resume');
|
|
1822
|
+
out('');
|
|
1823
|
+
const answer = (/** @type {string} */ tail) => `node scripts/run-u.mjs ${SELECTOR} --resume ${runid}${SHIM_TAIL}${SCOUT_TAIL}${tail} --approve ${specHash}`;
|
|
1824
|
+
for (const l of doorLines({
|
|
1825
|
+
rerun: answer(' --decide rerun --text "<what you want done differently>"'),
|
|
1826
|
+
accept: answer(' --decide accept'),
|
|
1827
|
+
pause: answer(' --decide pause'),
|
|
1828
|
+
})) out(l);
|
|
1829
|
+
out(` (the same command without --approve re-prints this package — the checkpoint keeps for ${PAUSE_TTL_MS / 86_400_000} days)`);
|
|
1830
|
+
}
|
|
1831
|
+
|
|
1832
|
+
// PRD item 33/M2, call site 3(b) — on a MINTED green only. `outcome` never
|
|
1833
|
+
// carries a distinct 'soft-green' string (src/run.js's own outcome union has
|
|
1834
|
+
// none): a judged close mints the same 'green' as a hard one, and the CLASS
|
|
1835
|
+
// lives on `spec.verdictType`/`REVIEW_DOOR_CLASSES` — so `outcome === 'green'`
|
|
1836
|
+
// already covers both, exactly as the spec's "green/soft-green" phrasing asks
|
|
1837
|
+
// for. A refused copy-out is reported beside the green it could not deliver
|
|
1838
|
+
// and NEVER changes `outcome` — the verdict is the close's, never this
|
|
1839
|
+
// module's to touch.
|
|
1840
|
+
if (outcome === 'green' && frontDoor) {
|
|
1841
|
+
// `into: dirname(wd)` — same review finding #3 fix as the preflight above,
|
|
1842
|
+
// so the two proofs of the same destination never disagree on what counts
|
|
1843
|
+
// as "contained". D3 rework: `destination` is a DIRECTORY now, and a job
|
|
1844
|
+
// may produce more than one file — `copyOut` delivers every non-empty file
|
|
1845
|
+
// under `output/`, each under its own dated name, and returns the list.
|
|
1846
|
+
const co = await copyOut({ tree: wd, into: dirname(wd), destination: frontDoor.destination });
|
|
1847
|
+
if (co.stop === null) {
|
|
1848
|
+
// `f.path`, never `frontDoor.destination` — each file lands under its
|
|
1849
|
+
// OWN dated name (M2b fix 4: `profile-2026-09-12.md`, `-2` the same
|
|
1850
|
+
// day), so the declared directory is not the path anything actually
|
|
1851
|
+
// wrote. The spine records what happened, not what was asked for.
|
|
1852
|
+
for (const f of co.files) {
|
|
1853
|
+
emit('destination-written', { path: f.path, declared: frontDoor.destination, bytes: f.bytes, sha256: f.sha256 });
|
|
1854
|
+
out(`\nDESTINATION written — ${f.path} (${f.bytes}B, sha256 ${f.sha256.slice(0, 12)}…)`);
|
|
1855
|
+
}
|
|
1856
|
+
} else {
|
|
1857
|
+
emit('destination-refused', { code: co.code, detail: co.stop });
|
|
1858
|
+
out(`\nDESTINATION NOT written — ${co.code}: ${co.stop}`);
|
|
1859
|
+
}
|
|
1860
|
+
}
|
|
1861
|
+
|
|
1862
|
+
// ── THE REGISTRY ROW (2B), minted BEFORE the door is rendered because the door's own
|
|
1863
|
+
// text reads off it. A bridge FILE is not a registry ROW, and the review door answers
|
|
1864
|
+
// rows: with none, `--door --decide accept` reached `recordDoor`, found no green row for
|
|
1865
|
+
// this runid and refused `no-row-for-run` — so the door could describe a held learning
|
|
1866
|
+
// credit it had no way to release.
|
|
1867
|
+
//
|
|
1868
|
+
// STORAGE ONLY. Nothing here selects, promotes or reuses a bridge (that rung is parked);
|
|
1869
|
+
// the row is written, and exactly one thing reads it back — the door. Every rule lives in
|
|
1870
|
+
// the library seam, which is where the reuse runner's greens go too, so there is ONE
|
|
1871
|
+
// spelling of what a green writes: no `--registry` is no row, an already-green run mints
|
|
1872
|
+
// nothing (accept confirms a verdict, it never mints one), and a green with no executed
|
|
1873
|
+
// plan mints nothing. hamr's ruling 2026-08-30 (PRD open item #16): a green-class run now
|
|
1874
|
+
// mints its row too, born RELEASED — a machine proved it, so there is no credit to
|
|
1875
|
+
// quarantine. Whether the minted row IS held is decided one level down: `greenParts`
|
|
1876
|
+
// reads the SIGNED class off the record, never because a line here set a flag.
|
|
1877
|
+
//
|
|
1878
|
+
// A SPINE LEAK BLOCKS IT, exactly as it blocks the bridge file below and for the same
|
|
1879
|
+
// reason: a spine carrying a secret must never graduate into an artifact that outlives
|
|
1880
|
+
// the run, and a registry row outlives it harder than a file does.
|
|
1881
|
+
const REGISTRY_ROW = leaks.length
|
|
1882
|
+
? { minted: false, reason: 'spine-leak', write: null }
|
|
1883
|
+
: writeRunGreenRow({
|
|
1884
|
+
registryDir: (ctx.registry ?? null),
|
|
1885
|
+
job: spec,
|
|
1886
|
+
name: (ctx.workflow ?? null),
|
|
1887
|
+
outcome,
|
|
1888
|
+
plan,
|
|
1889
|
+
record: {
|
|
1890
|
+
runid,
|
|
1891
|
+
patient: wd,
|
|
1892
|
+
at: new Date().toISOString(),
|
|
1893
|
+
costUsd: je?.spentUsd ?? null,
|
|
1894
|
+
// F44/F6 — completeness travels WITH the spend, and an absent job-end is
|
|
1895
|
+
// unknown-and-incomplete rather than a floor that reads as exact
|
|
1896
|
+
spendComplete: je?.spentUsd != null && je.spendComplete !== false,
|
|
1897
|
+
// FOLDED across a resume, like every other money and time number this readout
|
|
1898
|
+
// prints: the row records what the RUN cost, never what this leg cost
|
|
1899
|
+
wallMs: legMs + (dead ? dead.restart.priorWallMs : 0),
|
|
1900
|
+
// the ROUND count CANNOT fold — a resume leg writes a fresh spine and the halted
|
|
1901
|
+
// leg's restart record declares money and wall but never rounds — so on a resume
|
|
1902
|
+
// this is a FLOOR, and it says so the way the money above does (F6: a floor that
|
|
1903
|
+
// reads as exact is dishonest; report the floor WITH its completeness)
|
|
1904
|
+
rounds: legRounds,
|
|
1905
|
+
roundsComplete: !dead,
|
|
1906
|
+
specHash,
|
|
1907
|
+
},
|
|
1908
|
+
});
|
|
1909
|
+
if (REGISTRY_ROW.minted) {
|
|
1910
|
+
out(`\nREGISTRY ${REGISTRY_ROW.write.name} — ${REGISTRY_ROW.write.action} (${REGISTRY_ROW.write.file})`);
|
|
1911
|
+
// hamr's ruling 2026-08-30: a minted row is HELD only for a class `quarantinesCredit`
|
|
1912
|
+
// still names (soft-green today) — a green-class row is born RELEASED, because a
|
|
1913
|
+
// machine proved it and there was never a credit to quarantine
|
|
1914
|
+
out(quarantinesCredit(spec.verdictType)
|
|
1915
|
+
? ' HELD: this green\'s learning credit is quarantined until you release it at the door below'
|
|
1916
|
+
: ' RELEASED: a machine proved this green, so its row carries no credit to quarantine');
|
|
1917
|
+
} else if (REGISTRY_ROW.write !== null) {
|
|
1918
|
+
// a REFUSED write is said out loud rather than swallowed — the door below is about to
|
|
1919
|
+
// offer a disposition this run has no row to record
|
|
1920
|
+
out(`\nREGISTRY NOT written — ${REGISTRY_ROW.write.reds.map((r) => `${r.code}:${r.path}`).join(', ')}`);
|
|
1921
|
+
} else if ((ctx.registry ?? null) !== null) {
|
|
1922
|
+
// the operator NAMED a registry and no row was even attempted — silence here would
|
|
1923
|
+
// leave them guessing whether the flag was read at all. One line, the reason as-is
|
|
1924
|
+
// (green-predates-run / not-green / no-plan-executed / spine-leak);
|
|
1925
|
+
// no --registry stays silent, because nothing was asked for.
|
|
1926
|
+
out(`\nREGISTRY no row — ${REGISTRY_ROW.reason}`);
|
|
1927
|
+
}
|
|
1928
|
+
|
|
1929
|
+
// ── THE REVIEW DOOR, where the person is actually standing (module 8). The run
|
|
1930
|
+
// ENDED and its verdict is minted — this readout offers the three doors over it and
|
|
1931
|
+
// changes nothing about the outcome printed above. Read off the run's own record
|
|
1932
|
+
// (never off a flag), so a green that opened no door prints exactly what it always did.
|
|
1933
|
+
const doorHere = events.findLast((e) => e.type === 'review-door') ?? null;
|
|
1934
|
+
if (doorHere) {
|
|
1935
|
+
out('');
|
|
1936
|
+
for (const l of reviewDoorPackage({
|
|
1937
|
+
door: doorHere,
|
|
1938
|
+
diff: readDiff(Array.isArray(doorHere.changed?.paths) ? doorHere.changed.paths : []),
|
|
1939
|
+
})) out(l);
|
|
1940
|
+
out('');
|
|
1941
|
+
// the door is answered in a SEPARATE invocation, so the registry this run wrote its row
|
|
1942
|
+
// against has to travel in the printed command — an accept aimed at no registry (or at
|
|
1943
|
+
// a different one) is the `no-row-for-run` refusal all over again, one hop later.
|
|
1944
|
+
const doorRegistry = REGISTRY_ROW.minted ? ` --registry ${(ctx.registry ?? null)} --workflow ${REGISTRY_ROW.write.name}` : '';
|
|
1945
|
+
const answerDoor = (/** @type {string} */ tail) => `node scripts/run-u.mjs ${SELECTOR} --door ${runid}${tail}${doorRegistry} --approve ${specHash}`;
|
|
1946
|
+
// THE PROMISE READS OFF THE ROW, not off the class. `quarantined` on the door record
|
|
1947
|
+
// says this run's credit is HELD; whether there is anything to RELEASE is a second
|
|
1948
|
+
// question, and the honest answer is "only if a row was minted". Without `--registry`
|
|
1949
|
+
// there is none, and a door that promised a release it cannot perform is the defect
|
|
1950
|
+
// 2B closed — so the two are ANDed rather than the class alone being trusted.
|
|
1951
|
+
for (const l of runDoorLines({
|
|
1952
|
+
rerun: answerDoor(' --decide rerun --text "<what you want done differently>"'),
|
|
1953
|
+
accept: answerDoor(' --decide accept'),
|
|
1954
|
+
pause: answerDoor(' --decide pause'),
|
|
1955
|
+
ttlDays: PAUSE_TTL_MS / 86_400_000,
|
|
1956
|
+
held: doorHere.quarantined === true && REGISTRY_ROW.minted,
|
|
1957
|
+
})) out(l);
|
|
1958
|
+
if (doorHere.quarantined === true && !REGISTRY_ROW.minted) {
|
|
1959
|
+
out(' (this run\'s credit is HELD and no registry row was written, so an accept records a disposition '
|
|
1960
|
+
+ 'and releases nothing — name --registry/--workflow on the RUN to give the door something to release)');
|
|
1961
|
+
}
|
|
1962
|
+
out(` (the same command with no --decide re-prints this package — the door keeps for ${PAUSE_TTL_MS / 86_400_000} days)`);
|
|
1963
|
+
}
|
|
1964
|
+
// A leak is the HARD LINE broken, not a note in the margin: an advisory that
|
|
1965
|
+
// prints a count and then exits 0 while still writing the bridge is a guard in
|
|
1966
|
+
// name only. On any hit the run fails LOUD (non-zero) and the bridge is NOT
|
|
1967
|
+
// written — a spine carrying a secret must never graduate into a reusable
|
|
1968
|
+
// artifact that outlives the run. Count and PATH only, never the matched
|
|
1969
|
+
// content: echoing the secret to stdout/CI logs is the same leak, one hop on.
|
|
1970
|
+
if (leaks.length) {
|
|
1971
|
+
out(`\nSPINE LEAK: ${leaks.length} secret-shaped strings in ${spineFile} — the hard line is broken; BRIDGE NOT WRITTEN`);
|
|
1972
|
+
exitCode = 3; // distinct from 2 (operator/config) and 1 (stale --approve)
|
|
1973
|
+
}
|
|
1974
|
+
|
|
1975
|
+
// the BRIDGE: on a green the agent's own plan is kept as the reusable artifact
|
|
1976
|
+
if (outcome === 'green' && plan && !leaks.length) {
|
|
1977
|
+
// one file PER GREEN, never one file per job: two cold runs of this job produced
|
|
1978
|
+
// two DIFFERENT plans that both green (run 1: 3 steps, run 3: 2 steps), so a
|
|
1979
|
+
// single `bridge-<job>.json` silently destroys the earlier bridge. WHICH bridge
|
|
1980
|
+
// gets offered for reuse is a selection question the reuse rung answers
|
|
1981
|
+
// (PRD v1.34 item 3) — the runner must not answer it by clobbering.
|
|
1982
|
+
// `spec.job` is operator-authored JSON reaching a filesystem path here, so it is
|
|
1983
|
+
// checked AT THE USE SITE rather than trusted from a distance. Same spelling as
|
|
1984
|
+
// the shipped validator's SLUG_RE (src/job.js) — deliberately not a second,
|
|
1985
|
+
// looser alphabet: `.` would admit `..` and walk out of spineDir. In practice
|
|
1986
|
+
// validateJob reds a non-slug job before any green exists, so this is a boundary
|
|
1987
|
+
// assertion, not the primary defence; it REJECTS rather than rewrites because a
|
|
1988
|
+
// malformed job name is an operator mistake to surface, not to silently mask.
|
|
1989
|
+
if (!/^[a-z0-9][a-z0-9-]*$/.test(spec.job)) throw new Error(`spec.job ${JSON.stringify(spec.job)} is not a kebab-case slug — refusing to build a bridge filename from it (${SPEC_DESC})`);
|
|
1990
|
+
const bridgeFile = join(spineDir, `bridge-${spec.job}-${runid}.json`);
|
|
1991
|
+
writeFileSync(bridgeFile, `${JSON.stringify({ job: spec.job, specHash, runid, greenAt: new Date().toISOString(), plan }, null, 2)}\n`);
|
|
1992
|
+
out(`\nBRIDGE saved — ${bridgeFile}`);
|
|
1993
|
+
out(' the next run of this shape reuses this plan instead of starting cold');
|
|
1994
|
+
}
|
|
1995
|
+
// F67: if the outside guard fired, say so HERE rather than leaving the reader to
|
|
1996
|
+
// reconcile a truncated run against a file they don't know exists. (A kill it
|
|
1997
|
+
// completed leaves no readout at all — the marker beside the spine IS the record.)
|
|
1998
|
+
if (existsSync(`${spineFile}.watchdog.json`)) {
|
|
1999
|
+
const m = JSON.parse(readFileSync(`${spineFile}.watchdog.json`, 'utf8'));
|
|
2000
|
+
out(`\nWATCHDOG fired: ${m.reason} — the run was stopped from OUTSIDE, not by its own governance`);
|
|
2001
|
+
}
|
|
2002
|
+
// event-loop freezes the run survived — the sampler's record of WHERE the loop was blocked
|
|
2003
|
+
if (existsSync(lagFile)) {
|
|
2004
|
+
const lags = readFileSync(lagFile, 'utf8').trimEnd().split('\n').filter(Boolean).map((l) => JSON.parse(l));
|
|
2005
|
+
const worst = lags.reduce((a, b) => (b.blockedMs > a.blockedMs ? b : a));
|
|
2006
|
+
out(`\nLOOP FROZE ${lags.length}x — worst ${(worst.blockedMs / 1000).toFixed(1)}s, ${worst.from} → ${worst.until} (${lagFile})`);
|
|
2007
|
+
}
|
|
2008
|
+
// ── BEHAVIOUR (PRD build-list §2, agreed 2026-08-23): what the run DID with its
|
|
2009
|
+
// tools, read purely from this run's own gate audit — children included, since
|
|
2010
|
+
// `audit` is already scoped to this runid's file, never re-filtered here. Gates
|
|
2011
|
+
// nothing, mints no verdict; absence is reported as absence, never as a fabricated 0.
|
|
2012
|
+
if (audit.length) {
|
|
2013
|
+
out(`\nBEHAVIOUR ${formatBehaviour(runBehaviour(audit)).split('\n').join('\n ')}`);
|
|
2014
|
+
} else {
|
|
2015
|
+
out('\nBEHAVIOUR no gate audit recorded');
|
|
2016
|
+
}
|
|
2017
|
+
// ── MEMORY-CACHE (src/readshim.js, src/planrun.js): what the read shim saved
|
|
2018
|
+
// this run, read from the run's own `memory-cache` spine record. Report-only,
|
|
2019
|
+
// no cost fields — never folds into any spend line above. Armed-only: an
|
|
2020
|
+
// unarmed run (READ_SHIM_LEVERS.on false) prints nothing at all, and an armed
|
|
2021
|
+
// run whose spine has no record (a crash before the run-level `finally` fired)
|
|
2022
|
+
// says so rather than fabricating a zero line.
|
|
2023
|
+
if (READ_SHIM_LEVERS.on) {
|
|
2024
|
+
const mc = events.findLast((e) => e.type === 'memory-cache');
|
|
2025
|
+
if (mc) {
|
|
2026
|
+
const kb = (mc.bytesWithheld / 1024).toFixed(1);
|
|
2027
|
+
const kTokens = (mc.approxTokens / 1000).toFixed(1);
|
|
2028
|
+
out(`MEMORY-CACHE ${mc.pointered} re-reads answered from memory · ${mc.capped} reads capped · ${kb} KB withheld (~${kTokens}k tokens not re-sent)`);
|
|
2029
|
+
} else {
|
|
2030
|
+
out('MEMORY-CACHE no record (run ended before its summary)');
|
|
2031
|
+
}
|
|
2032
|
+
}
|
|
2033
|
+
out(`\nspine ${spineFile}`);
|
|
2034
|
+
out(`patient left AS THE RUN LEFT IT (read it before the next run resets to the seed)`);
|
|
2035
|
+
|
|
2036
|
+
return exitCode;
|
|
2037
|
+
} catch (/** @type {any} */ e) {
|
|
2038
|
+
if (e instanceof ExitSignal) return e.code;
|
|
2039
|
+
throw e;
|
|
2040
|
+
}
|
|
2041
|
+
}
|
|
2042
|
+
/**
|
|
2043
|
+
* Builds the shared `RunCtx` all three doors hand to `execute`.
|
|
2044
|
+
* @param {{spec: any, resume: string|null, door: string|null, decide: string|null, text: string|null}} mode
|
|
2045
|
+
* @param {RunOpts} opts
|
|
2046
|
+
* @returns {RunCtx}
|
|
2047
|
+
*/
|
|
2048
|
+
function buildCtx(mode, opts) {
|
|
2049
|
+
return {
|
|
2050
|
+
spec: mode.spec,
|
|
2051
|
+
specPath: opts.specDesc ?? (mode.spec && mode.spec.job) ?? '(spec)',
|
|
2052
|
+
selector: opts.selector ?? '--spec <spec>',
|
|
2053
|
+
specDesc: opts.specDesc ?? (mode.spec && mode.spec.job) ?? '(spec)',
|
|
2054
|
+
workdir: /** @type {string} */ (opts.workdir),
|
|
2055
|
+
seed: /** @type {string} */ (opts.seed),
|
|
2056
|
+
spineName: /** @type {string} */ (opts.spineName),
|
|
2057
|
+
model: opts.model ?? null,
|
|
2058
|
+
readShim: opts.readShim ?? null,
|
|
2059
|
+
scout: opts.scout ?? null,
|
|
2060
|
+
registry: opts.registry ?? null,
|
|
2061
|
+
workflow: opts.workflow ?? null,
|
|
2062
|
+
approve: opts.approve ?? null,
|
|
2063
|
+
reviewDoor: opts.reviewDoor === true,
|
|
2064
|
+
resume: mode.resume,
|
|
2065
|
+
door: mode.door,
|
|
2066
|
+
decide: mode.decide,
|
|
2067
|
+
text: mode.text,
|
|
2068
|
+
deps: opts.deps ?? {},
|
|
2069
|
+
};
|
|
2070
|
+
}
|
|
2071
|
+
|
|
2072
|
+
/**
|
|
2073
|
+
* A fresh run — today's `--job` / `--spec` path. `opts.workdir`/`opts.seed`/
|
|
2074
|
+
* `opts.spineName` name the patient (the JOBS-table row, or the `--spec`
|
|
2075
|
+
* manifest's own reading of it, when called through `main`/`scripts/run-u.mjs`).
|
|
2076
|
+
* @param {any} spec the parsed, not-yet-validated job spec
|
|
2077
|
+
* @param {RunOpts} [opts]
|
|
2078
|
+
* @returns {Promise<number>} an exit code — never calls process.exit
|
|
2079
|
+
*/
|
|
2080
|
+
export async function startRun(spec, opts = {}) {
|
|
2081
|
+
return execute(buildCtx({ spec, resume: null, door: null, decide: null, text: null }, opts));
|
|
2082
|
+
}
|
|
2083
|
+
|
|
2084
|
+
/**
|
|
2085
|
+
* A halted run — today's `--resume` path.
|
|
2086
|
+
* @param {string} runId the runid (or an explicit spine path, for tests)
|
|
2087
|
+
* @param {RunOpts & {decide?: string|null, text?: string|null}} [opts]
|
|
2088
|
+
* @returns {Promise<number>} an exit code — never calls process.exit
|
|
2089
|
+
*/
|
|
2090
|
+
export async function resumeRun(runId, opts = {}) {
|
|
2091
|
+
return execute(buildCtx({ spec: opts.spec, resume: runId, door: null, decide: opts.decide ?? null, text: opts.text ?? null }, opts));
|
|
2092
|
+
}
|
|
2093
|
+
|
|
2094
|
+
/**
|
|
2095
|
+
* A finished run's review door — today's `--door` path.
|
|
2096
|
+
* @param {string} runId the runid (or an explicit spine path, for tests)
|
|
2097
|
+
* @param {{decide: string|null, text?: string|null}} decision
|
|
2098
|
+
* @param {RunOpts} [opts]
|
|
2099
|
+
* @returns {Promise<number>} an exit code — never calls process.exit
|
|
2100
|
+
*/
|
|
2101
|
+
export async function answerDoor(runId, decision, opts = {}) {
|
|
2102
|
+
return execute(buildCtx({ spec: opts.spec, resume: null, door: runId, decide: decision?.decide ?? null, text: decision?.text ?? null }, opts));
|
|
2103
|
+
}
|
|
2104
|
+
|
|
2105
|
+
/**
|
|
2106
|
+
* Parses this runner's own argv shape — the JOBS table's `--job <key>` /
|
|
2107
|
+
* `--spec <path>`, plus `--resume`/`--door`/`--decide`/`--text`/
|
|
2108
|
+
* `--review-door`/`--approve`/`--registry`/`--workflow`/`--model`/
|
|
2109
|
+
* `--read-shim`/`--scout` — and dispatches to whichever of the three doors
|
|
2110
|
+
* above the flags name. `scripts/run-u.mjs` is the thin adapter over this,
|
|
2111
|
+
* the same shape `bin/bareloop.mjs` already is over `src/cli.js`'s own
|
|
2112
|
+
* `main(argv, deps)`.
|
|
2113
|
+
* @param {string[]} argv
|
|
2114
|
+
* @param {Deps} [deps]
|
|
2115
|
+
* @returns {Promise<number>}
|
|
2116
|
+
*/
|
|
2117
|
+
export async function main(argv, deps = {}) {
|
|
2118
|
+
const env = deps.env ?? process.env;
|
|
2119
|
+
const out = deps.out ?? ((/** @type {string} */ s) => { console.log(s); });
|
|
2120
|
+
const err = deps.err ?? ((/** @type {string} */ s) => { console.error(s); });
|
|
2121
|
+
/** @param {string} m @returns {never} */
|
|
2122
|
+
const die = (m) => { err(m); throw new ExitSignal(2); };
|
|
2123
|
+
const argFlag = (/** @type {string} */ n) => { const i = argv.indexOf(`--${n}`); return i === -1 ? null : (argv[i + 1] ?? ''); };
|
|
2124
|
+
|
|
2125
|
+
try {
|
|
2126
|
+
// F185 — a repo job authored through the PERSON path (run-interview.mjs →
|
|
2127
|
+
// run-author.mjs) used to have NO way to reach this runner: `--job` only ever
|
|
2128
|
+
// resolved a row from the hard-coded JOBS table below, and nothing wrote that
|
|
2129
|
+
// row or copied the authored spec into `jobs/` — a developer did both by hand.
|
|
2130
|
+
// hamr's ruling (2026-09-16, option A): `--spec <path to resolved-spec.json>`
|
|
2131
|
+
// is the other way to NAME a job, reading the prepared copy's OWN source
|
|
2132
|
+
// manifest (`readSourceManifest`, src/source.js) for the workdir and the seed
|
|
2133
|
+
// — never typed, never guessed, never defaulted — so there is no table row to
|
|
2134
|
+
// add and nothing to copy into `jobs/`. It is an alternative to `--job`, never
|
|
2135
|
+
// a second way to widen what either one already does: every gate below this
|
|
2136
|
+
// point (the `--approve` signature, the budget/wall ceilings, coldReset, the
|
|
2137
|
+
// close-first precheck) reads `spec`/`specHash`/`target` exactly as it always
|
|
2138
|
+
// has, whichever selector named them.
|
|
2139
|
+
const jobArg = argFlag('job');
|
|
2140
|
+
const specArg = argFlag('spec');
|
|
2141
|
+
if (jobArg !== null && specArg !== null) {
|
|
2142
|
+
die(`--job ${jobArg} and --spec ${specArg} name a job two different ways — give one. `
|
|
2143
|
+
+ '--job picks a row from this script\'s own table; --spec points at an authored resolved-spec.json '
|
|
2144
|
+
+ 'and reads its prepared copy\'s own source manifest for the workdir and seed (F185).');
|
|
2145
|
+
}
|
|
2146
|
+
if (jobArg === null && specArg === null) {
|
|
2147
|
+
die(`give one of --job <key> (one of: ${Object.keys(JOBS).join(', ')}) or --spec <path to resolved-spec.json> `
|
|
2148
|
+
+ '— a run has to name a job one of the two ways.');
|
|
2149
|
+
}
|
|
2150
|
+
/** @type {{spec: string, workdir: string, spine: string, seed: string}} */
|
|
2151
|
+
let target;
|
|
2152
|
+
/** the spec's own path, wherever it was found — a `jobs/` file for `--job`,
|
|
2153
|
+
* an arbitrary `resolved-spec.json` for `--spec`. */
|
|
2154
|
+
let specPath;
|
|
2155
|
+
/** every re-invocation command this script prints (resume/door/reopen) reuses
|
|
2156
|
+
* this so a `--spec` run's own commands stay `--spec`, never silently fall
|
|
2157
|
+
* back to `--job` (the same class of mislabel F186 named for the read shim). */
|
|
2158
|
+
let SELECTOR;
|
|
2159
|
+
/** how a "go edit the spec" hint renders — `jobs/<file>` for `--job`, the
|
|
2160
|
+
* spec's own path for `--spec` (it has no `jobs/` home to point at). */
|
|
2161
|
+
let SPEC_DESC;
|
|
2162
|
+
let spec;
|
|
2163
|
+
|
|
2164
|
+
if (jobArg !== null) {
|
|
2165
|
+
target = JOBS[/** @type {keyof typeof JOBS} */ (jobArg)];
|
|
2166
|
+
if (!target) { err(`unknown --job "${jobArg}" — one of: ${Object.keys(JOBS).join(', ')}`); throw new ExitSignal(2); }
|
|
2167
|
+
// THE SPEC IS THE OTHER HALF, AND IT MAY NOT BE THERE YET. A row in the table above
|
|
2168
|
+
// is the RUNNER's half of a job — the patient, its seed, where the spine goes. The
|
|
2169
|
+
// spec is the SIGNER's half: it is authored (scripts/run-interview.mjs →
|
|
2170
|
+
// scripts/run-author.mjs) and signed, and nothing here may invent one. A row whose
|
|
2171
|
+
// spec has not been authored yet used to die on `readFileSync`'s ENOENT stack, which
|
|
2172
|
+
// reads as the runner being broken rather than as the job being unauthored.
|
|
2173
|
+
specPath = fileURLToPath(new URL(`../jobs/${target.spec}`, import.meta.url));
|
|
2174
|
+
if (!existsSync(specPath)) {
|
|
2175
|
+
die(`--job ${jobArg}: there is no spec at ${specPath}.\n`
|
|
2176
|
+
+ ' The table row is the runner\'s half of a job (patient, seed, spine); the SPEC is yours — authored through\n'
|
|
2177
|
+
+ ` scripts/run-interview.mjs and scripts/run-author.mjs, then signed. Nothing here can stand in for it: a job\n`
|
|
2178
|
+
+ ' with no spec has no goal, no close, no budget and no hash to approve.');
|
|
2179
|
+
}
|
|
2180
|
+
try { spec = JSON.parse(readFileSync(specPath, 'utf8')); } catch (e) {
|
|
2181
|
+
die(`--job ${jobArg}: ${specPath} is not readable JSON (${e.message}) — a spec nobody can parse is a spec nobody signed`);
|
|
2182
|
+
}
|
|
2183
|
+
SELECTOR = `--job ${jobArg}`;
|
|
2184
|
+
SPEC_DESC = `jobs/${target.spec}`;
|
|
2185
|
+
} else {
|
|
2186
|
+
specPath = resolve(/** @type {string} */ (specArg));
|
|
2187
|
+
if (!existsSync(specPath)) {
|
|
2188
|
+
die(`--spec ${specPath} does not exist — a run needs the resolved-spec.json an authoring session actually wrote `
|
|
2189
|
+
+ '(scripts/run-author.mjs\'s own `out/resolved-spec.json`), never a path nobody authored');
|
|
2190
|
+
}
|
|
2191
|
+
let raw;
|
|
2192
|
+
try { raw = readFileSync(specPath, 'utf8'); } catch (e) {
|
|
2193
|
+
die(`--spec ${specPath}: cannot read (${/** @type {Error} */ (e).message})`);
|
|
2194
|
+
}
|
|
2195
|
+
try { spec = JSON.parse(/** @type {string} */ (raw)); } catch (e) {
|
|
2196
|
+
die(`--spec ${specPath} is not readable JSON (${e.message}) — a spec nobody can parse is a spec nobody signed`);
|
|
2197
|
+
}
|
|
2198
|
+
// validateJob'd HERE, at $0, before a single filesystem lookup for the
|
|
2199
|
+
// source manifest or a single line of the preview prints — a `--job` row's
|
|
2200
|
+
// spec is developer-authored and reaches this same check inside `runJob`
|
|
2201
|
+
// itself, further down; a `--spec` run gets it explicitly and early because
|
|
2202
|
+
// there is no developer standing between the person and this launch to have
|
|
2203
|
+
// caught a malformed spec first. `shellCapUsd: spec.budgetUsd` is the same
|
|
2204
|
+
// self-coupling `prepareSigning`'s own gate 1a and `runJob` below both use
|
|
2205
|
+
// (the budget bound this check enforces IS the spec's own advertised
|
|
2206
|
+
// number) — a bare `validateJob(spec)` would red every real spec here, since
|
|
2207
|
+
// the function's own default cap ($2) sits under every shipped job's budget.
|
|
2208
|
+
const jv = validateJob(spec, { shellCapUsd: typeof spec?.budgetUsd === 'number' ? spec.budgetUsd : undefined });
|
|
2209
|
+
if (!jv.ok) {
|
|
2210
|
+
die(`--spec ${specPath} fails validateJob — ${jv.reds.length} red(s):\n`
|
|
2211
|
+
+ jv.reds.map((r) => ` ${r.code} at ${r.path}${r.detail ? ` — ${r.detail}` : ''}`).join('\n'));
|
|
2212
|
+
}
|
|
2213
|
+
// THE PREPARED COPY'S OWN SOURCE MANIFEST — never typed, never guessed. A
|
|
2214
|
+
// `resolved-spec.json` written by run-author.mjs sits in the SAME `--out`
|
|
2215
|
+
// directory a source door's own `source-<runid>/` (source.json + tree/) was
|
|
2216
|
+
// prepared into (run-interview.mjs passes both the same OUT); so the one
|
|
2217
|
+
// directory beside the spec that carries a `source.json` IS the copy this
|
|
2218
|
+
// spec was authored against.
|
|
2219
|
+
let entries;
|
|
2220
|
+
try { entries = readdirSync(dirname(specPath), { withFileTypes: true }); } catch (e) {
|
|
2221
|
+
die(`--spec ${specPath}: ${dirname(specPath)} could not be listed (${/** @type {Error} */ (e).message}) — cannot find the prepared copy beside it`);
|
|
2222
|
+
}
|
|
2223
|
+
const candidates = entries
|
|
2224
|
+
.filter((e) => e.isDirectory() && e.name.startsWith('source-') && existsSync(join(dirname(specPath), e.name, 'source.json')))
|
|
2225
|
+
.map((e) => join(dirname(specPath), e.name));
|
|
2226
|
+
if (candidates.length === 0) {
|
|
2227
|
+
die(`--spec ${specPath}: no prepared copy beside it — looked for a source-*/ directory carrying source.json in `
|
|
2228
|
+
+ `${dirname(specPath)} and found none. A --spec run reads its workdir and seed off the copy the authoring `
|
|
2229
|
+
+ 'session prepared; it is never typed or guessed. Prepare one first: node scripts/run-interview.mjs (or '
|
|
2230
|
+
+ 'run-author.mjs directly against a --source) before running this spec.');
|
|
2231
|
+
}
|
|
2232
|
+
if (candidates.length > 1) {
|
|
2233
|
+
die(`--spec ${specPath}: ${candidates.length} prepared copies sit beside it (${candidates.join(', ')}) — `
|
|
2234
|
+
+ 'a --spec run cannot guess which one this spec was authored against; point --spec at a resolved-spec.json '
|
|
2235
|
+
+ 'with exactly one source-*/ sibling.');
|
|
2236
|
+
}
|
|
2237
|
+
const into = candidates[0];
|
|
2238
|
+
const manifestRead = await readSourceManifest(into);
|
|
2239
|
+
if (manifestRead.stop !== null) {
|
|
2240
|
+
die(`--spec ${specPath}: ${manifestRead.code} — ${manifestRead.stop}`);
|
|
2241
|
+
}
|
|
2242
|
+
if (!manifestRead.present || !manifestRead.manifest) {
|
|
2243
|
+
die(`--spec ${specPath}: ${into} carries no source.json — not a prepared copy this runner can trust`);
|
|
2244
|
+
}
|
|
2245
|
+
const seed = manifestRead.manifest.seed;
|
|
2246
|
+
if (typeof seed !== 'string' || !seed) {
|
|
2247
|
+
die(`--spec ${specPath}: ${into}/source.json carries no seed commit — not a prepared copy this runner can trust`);
|
|
2248
|
+
}
|
|
2249
|
+
const specWorkdir = join(into, 'tree');
|
|
2250
|
+
if (!existsSync(specWorkdir) || !existsSync(join(specWorkdir, '.git'))) {
|
|
2251
|
+
die(`--spec ${specPath}: the prepared copy's tree is gone (${specWorkdir} has no .git) — nothing to run against; re-prepare the source`);
|
|
2252
|
+
}
|
|
2253
|
+
target = {
|
|
2254
|
+
// display-only in --spec mode (SPEC_DESC below is what actually prints)
|
|
2255
|
+
spec: specPath,
|
|
2256
|
+
workdir: specWorkdir,
|
|
2257
|
+
// spine name derived from the spec's OWN signed job name — never hand-picked,
|
|
2258
|
+
// exactly the shape a JOBS row's own `<patient>-bareloop` convention already
|
|
2259
|
+
// is, just computed instead of typed by a developer.
|
|
2260
|
+
spine: `${spec.job}-bareloop`,
|
|
2261
|
+
seed,
|
|
2262
|
+
};
|
|
2263
|
+
SELECTOR = `--spec ${specPath}`;
|
|
2264
|
+
SPEC_DESC = specPath;
|
|
2265
|
+
}
|
|
2266
|
+
|
|
2267
|
+
const ctx = {
|
|
2268
|
+
spec,
|
|
2269
|
+
specPath,
|
|
2270
|
+
selector: SELECTOR,
|
|
2271
|
+
specDesc: SPEC_DESC,
|
|
2272
|
+
workdir: target.workdir,
|
|
2273
|
+
seed: target.seed,
|
|
2274
|
+
spineName: target.spine,
|
|
2275
|
+
model: argFlag('model'),
|
|
2276
|
+
readShim: argFlag('read-shim'),
|
|
2277
|
+
scout: argFlag('scout'),
|
|
2278
|
+
registry: argFlag('registry'),
|
|
2279
|
+
workflow: argFlag('workflow'),
|
|
2280
|
+
approve: argFlag('approve'),
|
|
2281
|
+
reviewDoor: argv.includes('--review-door'),
|
|
2282
|
+
resume: argFlag('resume'),
|
|
2283
|
+
door: argFlag('door'),
|
|
2284
|
+
decide: argFlag('decide'),
|
|
2285
|
+
text: argFlag('text'),
|
|
2286
|
+
deps,
|
|
2287
|
+
};
|
|
2288
|
+
// straight to the ONE shared engine, not through a door function: `ctx`
|
|
2289
|
+
// here already carries `resume` AND `door` exactly as argv gave them
|
|
2290
|
+
// (both, if the operator passed both), and it is `execute` itself that
|
|
2291
|
+
// must see both to raise the "two different runs in two different
|
|
2292
|
+
// states" refusal — picking a door here first would silently drop
|
|
2293
|
+
// whichever flag lost, and that refusal would never fire.
|
|
2294
|
+
return await execute(ctx);
|
|
2295
|
+
} catch (/** @type {any} */ e) {
|
|
2296
|
+
if (e instanceof ExitSignal) return e.code;
|
|
2297
|
+
throw e;
|
|
2298
|
+
}
|
|
2299
|
+
}
|