bareloop 0.5.1 → 0.7.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/types/ralph.d.ts CHANGED
@@ -60,9 +60,9 @@
60
60
  * (the workdir — the tree it is judging), the exit code the SIGNED spec calls
61
61
  * success, the judgment-rendered signal, and the kept-failures pattern. All
62
62
  * five are arbiter territory: the drafted workflow config cannot express any.
63
- * @returns {{verdict: 'satisfied'|'needs_revision'|'failed'|'timed-out'|'killed'|'crashed',
63
+ * @returns {Promise<{verdict: 'satisfied'|'needs_revision'|'failed'|'timed-out'|'killed'|'crashed',
64
64
  * gap?: string, exitCode?: number, signal?: string, detail?: string,
65
- * judgedCount?: number|null, unaudited?: boolean}}
65
+ * judgedCount?: number|null, unaudited?: boolean}>}
66
66
  */
67
67
  export function runClose(close: string[], redact?: (s: string) => string, { timeoutMs, cwd, expect, judged, gapKeep }?: {
68
68
  timeoutMs?: number;
@@ -73,7 +73,7 @@ export function runClose(close: string[], redact?: (s: string) => string, { time
73
73
  min: number;
74
74
  };
75
75
  gapKeep?: string;
76
- }): {
76
+ }): Promise<{
77
77
  verdict: "satisfied" | "needs_revision" | "failed" | "timed-out" | "killed" | "crashed";
78
78
  gap?: string;
79
79
  exitCode?: number;
@@ -81,7 +81,56 @@ export function runClose(close: string[], redact?: (s: string) => string, { time
81
81
  detail?: string;
82
82
  judgedCount?: number | null;
83
83
  unaudited?: boolean;
84
- };
84
+ }>;
85
+ /**
86
+ * Run a STAGED close (PRD v1.28) — an ordered list of named command stages, run
87
+ * in order until one renders a verdict that is not `satisfied`. The return is
88
+ * runClose's own shape, so every consumer (the precheck, ralph's judge, the
89
+ * escalation's CLOSE_FAULTS routing) is unchanged: a staged close is still ONE
90
+ * verdict, not a scoreboard.
91
+ *
92
+ * Two additions, both mechanical information the worker can act on:
93
+ * - `stage` names which stage rendered the verdict. The close's output must
94
+ * never name the CULPRIT FILE (v1.12/F28 — the worker finds that itself), and
95
+ * naming the wall it hit is the opposite of that: it is the mechanical genre
96
+ * that converts (F38/F46), not the answer.
97
+ * - `stages` is the ordered record of what actually ran. A stage after the
98
+ * first red never runs, so a later green can never mask an earlier failure.
99
+ *
100
+ * The same function runs a DERIVED CHECK: a check is its stage's chain
101
+ * (`checkMenu` puts the prerequisites first), so there is one execution path for
102
+ * the close and for the rulers derived from it — never two, which is the F9
103
+ * two-transforms class applied to the arbiter.
104
+ *
105
+ * @param {any[]} stages the stage list (already validated by validateJob)
106
+ * @param {(s: string) => string} [redact] scrub, applied at capture on EVERY stage
107
+ * @param {{timeoutMs?: number, cwd?: string}} [opts]
108
+ * @returns {Promise<any>} runClose's verdict shape, plus `stage` and `stages`
109
+ */
110
+ export function runStages(stages: any[], redact?: (s: string) => string, { timeoutMs, cwd }?: {
111
+ timeoutMs?: number;
112
+ cwd?: string;
113
+ }): Promise<any>;
114
+ /**
115
+ * The exhaustion-by-PROGRESS seam, as a contract rather than "whatever one module
116
+ * happens to return". Two governors implement it and they read different signals —
117
+ * the step ladder's repeats-and-writes (src/ladder.js) and the close-fix loop's
118
+ * per-stage graded numbers (src/trend.js) — which is the whole reason ralph takes an
119
+ * interface here and interprets nothing itself.
120
+ *
121
+ * `record` returns the governor's own reading, which ralph emits verbatim and never
122
+ * inspects; each reading names its `governor` so two instruments under one spine type
123
+ * can never be averaged into one number. `report` is the only shape ralph itself
124
+ * reads. `terminal` is optional and overrides the exhaustion PROSE only — never the
125
+ * category, never the outcome.
126
+ *
127
+ * @typedef {object} LoopGovernor
128
+ * @property {(o: {iteration: number, gap?: string}) => object} record
129
+ * @property {() => boolean} struckOut
130
+ * @property {() => {strikes: number, limit: number, iterations: number, distinctGaps?: number}} report
131
+ * @property {() => string} brief
132
+ * @property {() => {decision: string, options: string[]}} [terminal]
133
+ */
85
134
  /**
86
135
  * The loop: `while close-red and under-cap: run the middle`. Stops at first
87
136
  * green (within-run tuning past a visible close is the fit-to-pass surface,
@@ -107,7 +156,28 @@ export function runClose(close: string[], redact?: (s: string) => string, { time
107
156
  * zone, worker-crash routing (F32), and the cap taxonomy apply unchanged.
108
157
  * Injected by run.js only; inexpressible in any config or plan — the agent
109
158
  * never authors its judge any more than its close.
110
- * @param {number} opts.capRuns budget: max middle runs
159
+ * @param {number} [opts.capRuns] budget: max middle runs. Required UNLESS `ladder`
160
+ * is supplied — the two exhaustion rules are alternatives, never both at once.
161
+ * The "unless" is ENFORCED at entry (a param guard that throws), not merely
162
+ * documented: an optional-in-the-contract bound with nothing checking it made a
163
+ * loop that never ran report a governance stop that never happened.
164
+ * @param {LoopGovernor} [opts.ladder]
165
+ * the PROGRESS governor (src/ladder.js) that replaces the fixed count for a plan
166
+ * STEP's micro-loop. With it, the loop is bounded by strikes rather than by a
167
+ * number of runs: iterations float, and money, the wall, the variance meter and
168
+ * the stall fuse keep every bit of their current authority (each still ends the
169
+ * loop through the middle's own throw). Ralph still interprets nothing — it hands
170
+ * the ladder the iteration's gap and reads back a boolean.
171
+ *
172
+ * The CLOSE-FIX loop now supplies one too (PRD v1.46 §4, once its own $0 replay
173
+ * over all 8 archived fix loops came back clean: 0 greens harmed, 1 waste case
174
+ * caught). Its governor reads a different signal — the close's own per-stage
175
+ * graded numbers (src/trend.js), not repeats and writes — which is exactly why
176
+ * this is a SEAM and not a hard-coded rule: ralph still interprets nothing.
177
+ * An optional `terminal()` on the governor overrides the exhaustion prose, because
178
+ * the step ladder's copy offers the human a replan and there is no planner at the
179
+ * close. The category and the outcome are NOT overridable: one exhaustion terminal,
180
+ * two triggers.
111
181
  * @param {(type: string, data?: object) => object} opts.emit a spine emitter
112
182
  * @param {(s: string) => string} [opts.redact] source scrubber for close output
113
183
  * (secrets never enter the spine); injected so the shell stays stdlib-only
@@ -126,7 +196,7 @@ export function runClose(close: string[], redact?: (s: string) => string, { time
126
196
  * with zero writes, a crash stays what it always was: an instrument stop.
127
197
  * @returns {Promise<'green'|'escalated'>}
128
198
  */
129
- export function ralph({ middle, close, judge, capRuns, emit, redact, closeTimeoutMs, cwd, expect, judged, gapKeep, workerWrites }: {
199
+ export function ralph({ middle, close, judge, capRuns, ladder, emit, redact, closeTimeoutMs, cwd, expect, judged, gapKeep, workerWrites }: {
130
200
  middle: (iteration: number, gap?: string) => void | Promise<void>;
131
201
  close?: string[] | undefined;
132
202
  judge?: (() => Promise<{
@@ -138,7 +208,8 @@ export function ralph({ middle, close, judge, capRuns, emit, redact, closeTimeou
138
208
  gap?: string;
139
209
  detail?: string;
140
210
  }) | undefined;
141
- capRuns: number;
211
+ capRuns?: number | undefined;
212
+ ladder?: LoopGovernor | undefined;
142
213
  emit: (type: string, data?: object) => object;
143
214
  redact?: ((s: string) => string) | undefined;
144
215
  closeTimeoutMs?: number | undefined;
@@ -151,6 +222,40 @@ export function ralph({ middle, close, judge, capRuns, emit, redact, closeTimeou
151
222
  gapKeep?: string | undefined;
152
223
  workerWrites?: (() => string[]) | undefined;
153
224
  }): Promise<"green" | "escalated">;
225
+ /**
226
+ * What the close child must NOT be able to read (operator ruling, 2026-07-31:
227
+ * "strip it when unnecessary, minimize unneeded exposure").
228
+ *
229
+ * The close child executes WORKER-AUTHORED CODE. `pytest`, `npm test`, `tsc`
230
+ * run whatever the worker just wrote into the tree, in a child that — before
231
+ * this — inherited the whole operator environment, credentials included. None
232
+ * of git/npm/tsc/pytest/mypy needs an API key to judge a tree, so the exposure
233
+ * bought nothing and is removed. This is EXPOSURE REDUCTION, not a sandbox:
234
+ * see the threat-model note in the PRD (v1.41) and README for what remains.
235
+ *
236
+ * Two rules, deliberately kept BOTH ways round:
237
+ * - `names` / `prefixes` — the knowns. A literal denylist is a chase (every
238
+ * new provider mints a new variable), so it is not load-bearing on its own;
239
+ * it exists for names the shape rule STRUCTURALLY cannot see — either no
240
+ * `_`-bounded credential suffix at all (`AWS_ACCESS_KEY_ID`, `PGPASSWORD`,
241
+ * `MYSQL_PWD`), a `*_KEY` that is not `*_API_KEY` (`SSH_PRIVATE_KEY`), or a
242
+ * BARE spelling with nothing in front of the suffix (`TOKEN`, `SECRET`).
243
+ * Names are matched EXACTLY, which is what keeps connection config out of
244
+ * the strip (`PGHOST`, `SECRETS_DIR`, `TOKENIZERS_PARALLELISM` all survive).
245
+ * - `shape` — the NAME-SHAPE rule, which is what bounds the chase: anything
246
+ * whose name ends in the credential suffixes goes, whoever minted it and
247
+ * whether or not anyone here has heard of it. Case-insensitive; no `g` flag,
248
+ * so `.test` stays stateless and safe to reuse across every key.
249
+ *
250
+ * It is arbiter territory like the rest of this file: no drafted plan or config
251
+ * can express, widen, or opt out of it.
252
+ * @type {Readonly<{names: readonly string[], prefixes: readonly string[], shape: RegExp}>}
253
+ */
254
+ export const CLOSE_ENV_DENY: Readonly<{
255
+ names: readonly string[];
256
+ prefixes: readonly string[];
257
+ shape: RegExp;
258
+ }>;
154
259
  export const GAP_KEEP_TRIM_MARKER: "more elided by the";
155
260
  /**
156
261
  * The forbidden zone, named (PRD v1.11). Each row is an outcome in which the
@@ -170,3 +275,34 @@ export const CLOSE_FAULTS: Record<string, {
170
275
  decision: string;
171
276
  options: string[];
172
277
  }>;
278
+ /**
279
+ * The exhaustion-by-PROGRESS seam, as a contract rather than "whatever one module
280
+ * happens to return". Two governors implement it and they read different signals —
281
+ * the step ladder's repeats-and-writes (src/ladder.js) and the close-fix loop's
282
+ * per-stage graded numbers (src/trend.js) — which is the whole reason ralph takes an
283
+ * interface here and interprets nothing itself.
284
+ *
285
+ * `record` returns the governor's own reading, which ralph emits verbatim and never
286
+ * inspects; each reading names its `governor` so two instruments under one spine type
287
+ * can never be averaged into one number. `report` is the only shape ralph itself
288
+ * reads. `terminal` is optional and overrides the exhaustion PROSE only — never the
289
+ * category, never the outcome.
290
+ */
291
+ export type LoopGovernor = {
292
+ record: (o: {
293
+ iteration: number;
294
+ gap?: string;
295
+ }) => object;
296
+ struckOut: () => boolean;
297
+ report: () => {
298
+ strikes: number;
299
+ limit: number;
300
+ iterations: number;
301
+ distinctGaps?: number;
302
+ };
303
+ brief: () => string;
304
+ terminal?: (() => {
305
+ decision: string;
306
+ options: string[];
307
+ }) | undefined;
308
+ };
@@ -0,0 +1,388 @@
1
+ /**
2
+ * Validate the reuse envelope — the three numbers the operator signs before the run.
3
+ *
4
+ * `{ perTryBudgetUsd, perTryWallMs, bridgeTries }`, all REQUIRED and explicit. There is
5
+ * no default for any of them and there will not be one: hamr's ruling on `maxWallMs`
6
+ * generalises — a defaulted cap is a silent second ceiling, and a cap the operator did
7
+ * not state is a cap nobody chose.
8
+ *
9
+ * `bridgeTries: 0` is legal and means FORCE COLD: zero stored workflows are tried, the
10
+ * run drafts from scratch, and no selection call is made (nothing to select among).
11
+ *
12
+ * With a `job`, the composition rule is enforced: the per-try numbers may only TIGHTEN
13
+ * the signed spec. A per-try budget above `job.budgetUsd`, or a per-try wall above
14
+ * `job.maxWallMs`, is `envelope-widens` — the runner may narrow the operator's cap, never
15
+ * widen it. A spec that sets NO `maxWallMs` is time-unbounded by explicit choice, so any
16
+ * wall is a tightening of it.
17
+ *
18
+ * @param {unknown} input the operator's envelope
19
+ * @param {{job?: unknown}} [opts] the SIGNED spec, when the composition is being checked
20
+ * @returns {{ ok: boolean, reds: Red[], envelope: any }}
21
+ */
22
+ export function validateEnvelope(input: unknown, { job }?: {
23
+ job?: unknown;
24
+ }): {
25
+ ok: boolean;
26
+ reds: Red[];
27
+ envelope: any;
28
+ };
29
+ /**
30
+ * The PER-TRY spec: the signed job with the envelope's caps written onto it. Pure — the
31
+ * input spec is never mutated.
32
+ *
33
+ * This is what every try actually runs, cold leg included, so "$5 and 30 mins x2 then
34
+ * start anew" means the same numbers all the way down.
35
+ *
36
+ * **It is a spec VERSION, not a spec edit made behind the operator's back.** When the
37
+ * envelope equals the spec's own numbers the result is hash-IDENTICAL and the existing
38
+ * approval covers it. When it tightens, the hash MOVES — and `runJob`'s approval gate
39
+ * refuses the run until the operator has signed that hash. That refusal is the feature:
40
+ * the tightened numbers are the ones enforced, so they are the ones signed.
41
+ *
42
+ * @param {object} job the signed spec
43
+ * @param {{perTryBudgetUsd: number, perTryWallMs: number}} envelope a validated envelope
44
+ * @returns {object} the per-try spec
45
+ */
46
+ export function resolveTrySpec(job: object, envelope: {
47
+ perTryBudgetUsd: number;
48
+ perTryWallMs: number;
49
+ }): object;
50
+ /**
51
+ * The RESOLVED REUSE ARTIFACT — the thing the operator actually signs.
52
+ *
53
+ * The envelope is THREE numbers, and the third one is a MULTIPLIER: the worst case a
54
+ * reuse run authorizes is `perTryBudgetUsd × (bridgeTries + 1)`. A signature taken over
55
+ * the per-try spec alone covers only two of the three — `--tries 0` and `--tries 9` hash
56
+ * identically while authorizing ten times the spend. So the signed artifact is this
57
+ * WRAPPER, and `reuseSpecHash` is what an approval gate prints and compares.
58
+ *
59
+ * It is a wrapper rather than a field on the spec because `validateJob` reds every
60
+ * unknown top-level field: a `bridgeTries` written onto the per-try spec would flip the
61
+ * hash and then make every try a `job-red`. The spec half stays exactly the spec `runJob`
62
+ * executes, unchanged in kind.
63
+ *
64
+ * `bridgeTries` is READ ONCE, here, and the run's loop bound is read back off this same
65
+ * object — the number enforced and the number signed are the same number from the same
66
+ * place, never two reads of the envelope that a later edit could separate.
67
+ *
68
+ * @param {object} job the signed spec
69
+ * @param {{perTryBudgetUsd: number, perTryWallMs: number, bridgeTries: number}} envelope
70
+ * @returns {{schema: string, spec: any, bridgeTries: number}}
71
+ */
72
+ export function resolveReuse(job: object, envelope: {
73
+ perTryBudgetUsd: number;
74
+ perTryWallMs: number;
75
+ bridgeTries: number;
76
+ }): {
77
+ schema: string;
78
+ spec: any;
79
+ bridgeTries: number;
80
+ };
81
+ /**
82
+ * The APPROVAL HASH: one number covering all three envelope numbers.
83
+ *
84
+ * Composed from the repo's ONE spec hasher rather than re-canonizing the wrapper, so the
85
+ * per-try spec half is hashed exactly as `jobSpecHash` hashes it — MED-1's resolved-`tools`
86
+ * pinning included, and with no second canonicalizer that could drift from it. The
87
+ * `reuse-v1` prefix is domain separation: a reuse approval can never collide with a bare
88
+ * job approval.
89
+ *
90
+ * **This is a signing-SCHEME change, and it invalidates every hash signed before it — by
91
+ * design.** A signature that did not cover the try count never covered the run's worst
92
+ * case, so it is not carried forward: there is no legacy acceptance path, and re-signing
93
+ * once is the whole migration.
94
+ *
95
+ * @param {{spec: any, bridgeTries: number}} resolved a `resolveReuse` artifact
96
+ * @returns {string} sha256 hex
97
+ */
98
+ export function reuseSpecHash(resolved: {
99
+ spec: any;
100
+ bridgeTries: number;
101
+ }): string;
102
+ /**
103
+ * Ask the model which stored workflow (if any) this run should start from — ONE call, on
104
+ * the drafter-tier provider the CALLER hands in. No provider is constructed here: the
105
+ * provider binding is shell territory, exactly as it is for every other call in this
106
+ * repo.
107
+ *
108
+ * The three operator overrides ride into the same call rather than around it:
109
+ * - `forceCold` skips everything ($0, no tokens) — the operator has already decided.
110
+ * - `shortlist` restricts the LISTING to those names, so the model chooses among the
111
+ * operator's set and cannot reach past it.
112
+ * - `pinned` does NOT bypass the call. D3: the model may refuse a pin only EXPLICITLY,
113
+ * with a reason. So the pin is stated in the prompt, and if the answer names anything
114
+ * other than the pin the result is `{refused: true}` — the substitute is NOT adopted,
115
+ * and the decision goes back to the operator. Neither direction is silent.
116
+ *
117
+ * An EMPTY candidate set also skips the call: paying a model to pick from an empty
118
+ * listing buys nothing, and "there is nothing to reuse" is not a judgment call.
119
+ *
120
+ * The answer is parsed with the repo's ONE model-output parser (`extractArtifact`) and
121
+ * then strictly: an object with a `choice` that is null or a name FROM THE LISTING, plus
122
+ * a `reason`. A name that is not on the listing is a named red, never used — a picker
123
+ * that can name a workflow nobody offered is a picker that can reach outside the
124
+ * operator's set.
125
+ *
126
+ * Cost is metered and reported (F6): `costUsd` is the priced figure or an explicit null,
127
+ * and `spendComplete` says whether that figure is exact. A skipped call is a real 0.
128
+ *
129
+ * @param {object} opts
130
+ * @param {unknown} opts.registry `loadRegistry`'s result, or a bare array of entries
131
+ * @param {object} opts.job the job this run is signed against (only its goal is used here)
132
+ * @param {string} opts.ask the ask, in the operator's own words
133
+ * @param {any} opts.provider the drafter-tier provider binding (caller-owned)
134
+ * @param {string|null} [opts.pinned] the operator's pin
135
+ * @param {string[]|null} [opts.shortlist] the operator's shortlist
136
+ * @param {boolean} [opts.forceCold]
137
+ * @param {string[]} [opts.exclude] names already tried this run
138
+ * @returns {Promise<any>} `{ choice, reason, called, refused, forcedCold, red, costUsd,
139
+ * spendComplete, candidates, listing }`
140
+ */
141
+ export function selectBridge({ registry, job, ask, provider, pinned, shortlist, forceCold, exclude }: {
142
+ registry: unknown;
143
+ job: object;
144
+ ask: string;
145
+ provider: any;
146
+ pinned?: string | null | undefined;
147
+ shortlist?: string[] | null | undefined;
148
+ forceCold?: boolean | undefined;
149
+ exclude?: string[] | undefined;
150
+ }): Promise<any>;
151
+ /**
152
+ * Read a killed reuse run's OWN SPINE back into the state a resume continues from.
153
+ *
154
+ * hamr's checkpoint ruling is the whole contract: *"money, signature and checkpoint
155
+ * (starts from where it stopped) if mid loop, restart that loop"*. So this reader
156
+ * answers exactly four questions, and nothing else:
157
+ *
158
+ * 1. **Which tries COMPLETED** — they are never re-run, and their bridges stay
159
+ * spoken for exactly as if the loop had carried on.
160
+ * 2. **Was the process mid-try** — a `try-start` with no matching `try-end`. That
161
+ * try was never graded, so it is not consumed: it RESTARTS from its beginning.
162
+ * 3. **What that dead attempt already spent, in money and in wall time** — folded
163
+ * into the restart so it runs under the REMAINDER of its signed per-try numbers.
164
+ * 4. **Which envelope this spine belongs to** — the `reuse-start` record's own
165
+ * approval hash, so a resume can prove it is continuing the SAME signed run.
166
+ *
167
+ * **F45 governs the arithmetic here.** A window's spend is summed from `worker-round`
168
+ * ONLY — the same event `runJob`'s own ledger accounts, no other. The selection calls
169
+ * are real money and are counted, but as the RUN's cost and never as a try's: a
170
+ * picker's tokens are not a worker's, and attributing them to a try would misstate
171
+ * both the fold and the row. `worker-turn` (native attribution, cost null by design)
172
+ * is deliberately not summed, exactly as the live ledger does not sum it.
173
+ *
174
+ * **The declared fold, not a re-derivation.** A restarted try's own `try-start`
175
+ * carries the fold it inherited (`priorSpentUsd`/`priorWallMs`), so a resume OF a
176
+ * resume adds only its own new rounds. Re-deriving from the whole file would bill an
177
+ * abandoned attempt twice.
178
+ *
179
+ * **KNOWN LIMIT, stated rather than discovered later.** A call that was killed BEFORE it
180
+ * returned left no event at all — no `worker-round`, no `selection-result` — so money it
181
+ * may already have been billed for is not on the spine and cannot be folded. This reader
182
+ * can only account writers that WROTE (the same limit the runner's own guards have: a
183
+ * hung `generate()` leaves zero trace). The fold is therefore a floor in exactly the case
184
+ * `priorSpendComplete` already marks, and one lost selection call is re-paid by the
185
+ * resumed pick rather than silently attributed to a try.
186
+ *
187
+ * **A try whose `job-end` landed is COMPLETE, not mid-flight.** The close already
188
+ * rendered its verdict; restarting would pay a second time for an answer already in
189
+ * hand. When the death fell between that verdict and the registry write, the row that
190
+ * was lost is NAMED (`r1Missing`) rather than silently absent — and never re-derived,
191
+ * because a green's version has to be the plan AS EXECUTED and this reader is not
192
+ * where that is decided.
193
+ *
194
+ * **THE HALT AMENDMENT (PRD v1.46 §3).** "A landed `job-end` is COMPLETE" is true of
195
+ * a VERDICT, and a governance halt is not one: a money cap-halt (and, W-2-symmetric,
196
+ * a wall-halt) means the run ran out of an operator-owned allowance with its work on
197
+ * disk and its plan on the spine — the exact case hamr's ruling was about ("why would
198
+ * i want to waste more money on something i already started"). `resumableOutcomes`
199
+ * names which terminals get read that way. It is EMPTY by default, so the reuse
200
+ * loop's own semantics are byte-unchanged: there, a cap-halted try was graded, its
201
+ * registry row was written, and turning it into a restart would re-run a try whose
202
+ * fold leaves it no money at all and duplicate its row. Green and every red stay
203
+ * non-resumable under any setting — a verdict already rendered is never re-bought.
204
+ *
205
+ * **DIRECT SPINES (`direct`).** A `runJob` spine has no `reuse-start` and no try
206
+ * windows, so this reader saw one as "not a reuse run" and every priced round in it
207
+ * as a STRAY. Opted in, the run itself is read as ONE implicit try opened at
208
+ * `job-start` — the same window machinery, never a second reader, because the whole
209
+ * failure class this reader exists to survive (F45) is the writer nobody modelled.
210
+ * The fold it declares comes off `job-start`'s own `priorSpentUsd`, exactly as a
211
+ * restarted try's comes off its `try-start`: re-deriving from the file would bill an
212
+ * abandoned attempt twice in a chain of resumes.
213
+ *
214
+ * @param {any[]} events the dead spine's events, parsed, in file order
215
+ * @param {{deathAt?: number|null, direct?: boolean, resumableOutcomes?: string[]}} [opts]
216
+ * `deathAt`: when the process is judged to have died (the watchdog's kill record,
217
+ * which is later and better evidence than the last event). Defaults to the last
218
+ * event's own timestamp — the last sign of life there is.
219
+ * @returns {any} `{ started, approvalHash, specHash, patient, job, bridgeTries,
220
+ * perTryBudgetUsd, perTryWallMs, ended, endOutcome, greened, completed, tried,
221
+ * selectionCostUsd, selectionComplete, strayRounds, r1Missing, restart, deathAtKnown,
222
+ * spentUsd, spendComplete, carrySpentUsd, carrySpendComplete }`
223
+ */
224
+ export function readResume(events: any[], { deathAt, direct, resumableOutcomes }?: {
225
+ deathAt?: number | null;
226
+ direct?: boolean;
227
+ resumableOutcomes?: string[];
228
+ }): any;
229
+ /**
230
+ * The PATIENT gate for a resume, which is deliberately NOT the fresh-launch one.
231
+ *
232
+ * A fresh reuse run refuses a dirty tree: a run that inherits the previous run's edits
233
+ * measures the wrong thing. A RESUME is the opposite case — the dead tries' work is real
234
+ * progress this run already paid for, the tries share one workdir by design, and the tree
235
+ * is reset between RUNS by the operator, never by the harness. So a dirty tree is exactly
236
+ * what a resume expects to find, and it is continued as the run left it: not reset, not
237
+ * refused.
238
+ *
239
+ * What IS refused is HEAD moving off the seed. That is not the run's own work — nothing
240
+ * in the flow commits — so it means a human committed, rebased or checked something out
241
+ * under the dead run. The reconstruction's premise (this tree is where try N left it) no
242
+ * longer holds, and that decision comes back to the operator.
243
+ *
244
+ * @param {{head: string, seed: string, dirty: string}} state as `git rev-parse HEAD` and
245
+ * `git status --porcelain` report it
246
+ * @returns {{ok: boolean, detail: string|null}}
247
+ */
248
+ export function resumeTreeGate({ head, seed, dirty }: {
249
+ head: string;
250
+ seed: string;
251
+ dirty: string;
252
+ }): {
253
+ ok: boolean;
254
+ detail: string | null;
255
+ };
256
+ /**
257
+ * Run a job under the reuse envelope: select a stored workflow, try it, and — if the
258
+ * envelope pre-authorized it — try the next one, then draft cold.
259
+ *
260
+ * The shape, end to end:
261
+ *
262
+ * 1. **Envelope** validated against the SIGNED spec (tighten-only). A red stops here,
263
+ * before the registry is read and before anything is spent.
264
+ * 2. **Registry** loaded from the operator-supplied directory (never a default location).
265
+ * A missing directory is a named red; a malformed ENTRY is skipped, reported, and
266
+ * carried into the listing so the picker is never handed a silently shorter set.
267
+ * 3. **Up to `bridgeTries` tries.** Each try re-runs selection against a FRESHLY RELOADED
268
+ * registry with the already-tried names excluded — so the next pick sees the row the
269
+ * last try just wrote, and cannot re-pick a workflow this run already spent money on.
270
+ * The chosen entry rides into `runJob`, whose own load gate is the D2 shape check; a
271
+ * gate refusal (`recipe-stale`) costs $0 and simply moves to the next candidate.
272
+ * 4. **After each try the box is written (R1)** — green appends a version + a row, a
273
+ * graded red appends a row, a casualty appends a row under its own name. Saved
274
+ * atomically, one write per try.
275
+ * 5. **A green ENDS the loop.** The job is done; there is nothing left to try.
276
+ * 6. **Tries exhausted → the cold leg**, under the SAME per-try numbers ("then start
277
+ * anew"). Its green mints a NEW bridge named for the job slug — or appends to the
278
+ * entry of that name if one already exists, because clobbering a green is exactly the
279
+ * thing R1 exists to prevent.
280
+ *
281
+ * Fallthrough is automatic ONLY because the envelope pre-authorized it (D7) — that is
282
+ * what makes it not a per-failure nag, and it is also why nothing else here is automatic:
283
+ * a refused pin, an unreadable selection answer and a missing registry all STOP and hand
284
+ * the decision back.
285
+ *
286
+ * **RESUME (module C).** With `resume` — a `readResume` reading of the dead run's own
287
+ * spine — the same loop picks up where a killed run stopped: the completed tries are
288
+ * seeded in (never re-run, their bridges never re-offered), a try that was mid-flight is
289
+ * RESTARTED from its beginning against the SAME workflow and with NO second selection
290
+ * call (the pick was made and paid for), and it runs under the REMAINDER of its own
291
+ * signed per-try numbers. The remainder is expressed as a FOLD of the dead attempt's
292
+ * spend and wall — never as tightened caps — because the caps are in the spec hash and
293
+ * rewriting them would need a signature nobody typed. If the remainder cannot fund the
294
+ * restart the run caps honestly, having launched nothing.
295
+ *
296
+ * @param {object} opts
297
+ * @param {object} opts.job the signed job spec
298
+ * @param {unknown} opts.approvals approval records — passed straight through to `runJob`;
299
+ * this module never mints one (a tightened envelope makes a NEW spec version, and the
300
+ * operator signs it)
301
+ * @param {string} opts.registryDir the operator-supplied bridge registry
302
+ * @param {unknown} opts.envelope `{ perTryBudgetUsd, perTryWallMs, bridgeTries }`
303
+ * @param {string} opts.patient the instance this run works on — the distinct-patient
304
+ * count IS the status ladder (D6/R2), so it is required and never guessed from a path
305
+ * @param {string} opts.workdir the run directory
306
+ * @param {any} opts.provider the worker provider binding
307
+ * @param {any} [opts.selectionProvider] the drafter-tier provider for the selection call
308
+ * (defaults to `provider`)
309
+ * @param {(type: string, data?: object) => object} opts.emit spine emitter
310
+ * @param {string} [opts.ask] the ask in the operator's words (defaults to the job's goal)
311
+ * @param {string|null} [opts.pinned]
312
+ * @param {string[]|null} [opts.shortlist]
313
+ * @param {boolean} [opts.forceCold]
314
+ * @param {string} [opts.runid] the run id the bridge rows point back at (defaults to a
315
+ * fresh one); the operator runner passes its own so a row names its spine file
316
+ * @param {any} [opts.providerFor] per-step model-tier factory, forwarded
317
+ * @param {any} [opts.nativeProvider] native provider factory, forwarded
318
+ * @param {number} [opts.capRuns] forwarded
319
+ * @param {number} [opts.strikeLimit] forwarded (a plan step's strike ceiling)
320
+ * @param {number} [opts.closeTimeoutMs] forwarded
321
+ * @param {boolean} [opts.layerRoot] forwarded
322
+ * @param {any} [opts.resume] RESUME (module C) — a `readResume` reading of the KILLED
323
+ * run's own spine. Its `approvalHash` must match this run's, or the run refuses:
324
+ * a resume continues ONE signed run, not any run.
325
+ * @param {typeof shippedRunJob} [opts.runJob] the job runner. An injected seam of the
326
+ * same class as `provider`: a try is a real paid run, and the loop's own logic —
327
+ * selection order, exclusion, minting, exhaustion — is not testable against one.
328
+ * @param {() => number} [opts.now] the clock, injected (per-try wall measurement)
329
+ * @returns {Promise<any>} `{ outcome, tries, selection, spentUsd, spendComplete,
330
+ * bridgeWrites, decision, options, reds, triesAuthorized, triesUsed, envelope, specHash,
331
+ * approvalHash }` — `specHash` is the per-try spec's (what `runJob`'s own gate reads);
332
+ * `approvalHash` is the OPERATOR's, covering all three envelope numbers
333
+ */
334
+ export function runReuse(opts: {
335
+ job: object;
336
+ approvals: unknown;
337
+ registryDir: string;
338
+ envelope: unknown;
339
+ patient: string;
340
+ workdir: string;
341
+ provider: any;
342
+ selectionProvider?: any;
343
+ emit: (type: string, data?: object) => object;
344
+ ask?: string | undefined;
345
+ pinned?: string | null | undefined;
346
+ shortlist?: string[] | null | undefined;
347
+ forceCold?: boolean | undefined;
348
+ runid?: string | undefined;
349
+ providerFor?: any;
350
+ nativeProvider?: any;
351
+ capRuns?: number | undefined;
352
+ strikeLimit?: number | undefined;
353
+ closeTimeoutMs?: number | undefined;
354
+ layerRoot?: boolean | undefined;
355
+ resume?: any;
356
+ runJob?: typeof shippedRunJob | undefined;
357
+ now?: (() => number) | undefined;
358
+ }): Promise<any>;
359
+ /** @typedef {{code: string, path: string, detail?: string}} Red */
360
+ /**
361
+ * The GRADED-RED set: the runJob outcomes in which the operator's close actually
362
+ * rendered a verdict on the tree and that verdict was RED. Only these demote (D6).
363
+ *
364
+ * `escalated` is the one. It is the terminal runPlan returns when the close judged the
365
+ * tree, the bounded fix loop ran its attempts with money still on the table, and the
366
+ * close was still red — i.e. the recipe was graded by the job's own truth and failed.
367
+ *
368
+ * Everything else non-green is a CASUALTY and keeps its own name:
369
+ * - `cap-halt` / `wall-halt` — governance stops. A money cut is never a capability read
370
+ * (F45) and the wall keeps the verdict already minted (W-2).
371
+ * - `provider-red` / `step-stalled` / `pricing-red` — transport and metering.
372
+ * - `close-red` / `close-unsupported` — the close FAULTED (broken, timed out, killed,
373
+ * crashed). It rendered no judgment, so its exit says nothing about the tree.
374
+ * - `recipe-stale` — the load gate refused the recipe at the door, $0 spent.
375
+ * - `plan-red` / `step-red:<id>` / `check-red` — the plan flow stopped before the close
376
+ * ever judged. Read as evidence about the RECIPE these would be a guess; they are
377
+ * recorded in full on the history row, which is where D6 puts the detail a human
378
+ * reads, and the coarse status ladder is left alone.
379
+ * - `unapproved-spec` / `job-red` / `smoke-red` / `interpreter-red` — the run never
380
+ * started.
381
+ */
382
+ export const REUSE_GRADED_RED: readonly string[];
383
+ export type Red = {
384
+ code: string;
385
+ path: string;
386
+ detail?: string;
387
+ };
388
+ import { runJob as shippedRunJob } from './run.js';
package/types/root.d.ts CHANGED
@@ -66,13 +66,21 @@ export function createRoot({ gapKeep, redact, writesInformative }?: {
66
66
  * Called at the START of attempt `iteration`, with the PREVIOUS attempt's
67
67
  * gap. Finalizes that attempt from the books, runs the detector, and
68
68
  * returns the injection for the attempt about to run — or null (inert).
69
- * @param {{iteration: number, gap?: string, writes: string[]}} o
69
+ *
70
+ * `redStage`/`redKeep` are the STAGED-CLOSE seam (PRD v1.28): a close is an
71
+ * ordered list of named stages, the first red renders the verdict, so the
72
+ * pattern that designates failures belongs to whichever stage rendered it
73
+ * THIS attempt. Callers with a single red-set source (the per-step exit
74
+ * evaluator) pass neither and keep the constructor's run-wide pattern.
75
+ * @param {{iteration: number, gap?: string, writes: string[], redStage?: string, redKeep?: string}} o
70
76
  * @returns {null | {stage: 'summary'|'verbatim', note: string, event: object}}
71
77
  */
72
- observe({ iteration, gap, writes }: {
78
+ observe({ iteration, gap, writes, redStage, redKeep }: {
73
79
  iteration: number;
74
80
  gap?: string;
75
81
  writes: string[];
82
+ redStage?: string;
83
+ redKeep?: string;
76
84
  }): null | {
77
85
  stage: "summary" | "verbatim";
78
86
  note: string;