bareloop 0.7.0 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/bridges.js CHANGED
@@ -36,7 +36,7 @@ import { existsSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync, s
36
36
  import { join } from 'node:path';
37
37
  import { isObj, isNonEmptyString } from './validate.js';
38
38
  import { TOOL_MENU, LOCKED_TOOLS } from './job.js';
39
- import { stageClose } from './plan.js';
39
+ import { closeStagesOf } from './plan.js';
40
40
 
41
41
  export const BRIDGE_SCHEMA = 'bridge-v1';
42
42
 
@@ -460,7 +460,7 @@ export function loadGate(bridge, job) {
460
460
  }
461
461
 
462
462
  // 2. close-stage kinds (v1: names in order)
463
- const stages = (stageClose(j.close) ?? []).map((/** @type {any} */ s) => (isObj(s) ? s.name : null));
463
+ const stages = (closeStagesOf(j) ?? []).map((/** @type {any} */ s) => (isObj(s) ? s.name : null));
464
464
  const stored = Array.isArray(b.closeStageNames) ? b.closeStageNames : null;
465
465
  if (!stored || stored.length !== stages.length || !stored.every((/** @type {unknown} */ n, /** @type {number} */ i) => n === stages[i])) {
466
466
  red('closeStageNames', `bridge close [${(stored ?? ['<none>']).join(' → ')}] vs this job's close [${(stages.length ? stages : ['<none>']).join(' → ')}] — same names in the same order is v1's definition of the same kinds`);
@@ -0,0 +1,519 @@
1
+ // THE RUNTIME BRIDGE — close-authoring v1, module M4a (design record
2
+ // 2026-08-07 D8/D9/D12/D13; gate-2 POC closed 2026-08-08).
3
+ //
4
+ // M1 runs a DECLARATION. The arbiter's shell (`src/ralph.js`) runs an ARGV. This
5
+ // module is the one place those two shapes meet, and it exists because of the
6
+ // integration decision M4 had to make in the open:
7
+ //
8
+ // A declared close is carried by a NEW spec field (`closeDecl`) and executed by
9
+ // M1's own kind executor. It is never COMPILED DOWN to command stages.
10
+ //
11
+ // The rejected alternative is the one that looks cheaper: render each declared
12
+ // stage as a shell one-liner and let the existing `close[]` path run it. That
13
+ // would turn owned kinds back into authored shell strings — the exact thing D3
14
+ // makes inexpressible ("the danger is in the ACTIONS, not the SYNTAX"), and the
15
+ // exact thing the whole rung exists to remove. A declaration whose meaning is a
16
+ // generated `sh -c` is a script with extra steps, and the arbiter would be
17
+ // judging text nobody validated. So: two executors, one CONTRACT, and the
18
+ // contract is what this file holds still.
19
+ //
20
+ // WHAT "one contract" MEANS, concretely — every one of these is a behaviour the
21
+ // command path already had and the declared path must not lose:
22
+ //
23
+ // * FIRST RED WINS, and the deciding stage NAMES itself. A stage after the
24
+ // first red never runs, so a later green cannot mask an earlier failure.
25
+ // * THE FORBIDDEN ZONE (`CLOSE_FAULTS`). A stage that rendered no judgment is
26
+ // NOT a verdict: it routes by its own typed fault (`STOP_FAULTS`, stamped in
27
+ // M1 at the site that observed it) into the arbiter's four rows, so a
28
+ // timeout still offers "raise the close timeout" and a spawn failure still
29
+ // offers "fix the close". Pooling them would erase exactly the decision
30
+ // information the escalation carries.
31
+ // * THE GAP HEADER. `close stage "<name>" failed:` — the SAME spelling, from
32
+ // `stageGap` in ralph.js, because `src/trend.js` parses it to bucket a grade
33
+ // per stage. A second spelling here is the two-transforms class (F9) on the
34
+ // instrument that decides whether a human tops a run up.
35
+ // * THE GAP OUTPUT CONTRACT: every line prefixed, trims announced (M1 owns
36
+ // that), and the whole thing SCRUBBED at this boundary through the one
37
+ // `SECRET_PATTERNS` inventory — M1 deliberately does not scrub, the caller
38
+ // does, and this is the caller.
39
+ // * THE CHECK MENU derives from stage NAMES exactly as before (`checkMenu` in
40
+ // src/job.js reads `name`/`offer`/`needs` and nothing else), so
41
+ // `check-passes(name)` reaches a declared stage with no change at all.
42
+ //
43
+ // TWO THINGS THIS BRIDGE ADDS, both because the declared executor knows more
44
+ // than an exit code does:
45
+ //
46
+ // 1. `gapKeep`. Every declared gap line carries a fixed PREFIX, so the anchored
47
+ // pattern derived from it designates the stage's own failures — which is
48
+ // what Layer R's red-set reads off `stagedClose.find(...).gapKeep`. It is
49
+ // ours, derived mechanically from one constant, and never model-authored:
50
+ // the declaration has no field for it.
51
+ // 2. A TREND VALUE. `count-not-worse` reports a real number, so the grade does
52
+ // not have to be scraped back out of prose. It is donated ONLY for
53
+ // `lower-is-better`, and the omission is the load-bearing half: trend.js
54
+ // reads "improved" as `value < best`, so donating a higher-is-better floor
55
+ // would read a DROPPING test count as convergence and recommend a top-up on
56
+ // a dying run. That is the dangerous direction; a null is the honest one
57
+ // (F6), and the stage position still travels.
58
+ //
59
+ // THE SEED (D8). HEAD at run start, READ and recorded, never typed and never
60
+ // stored in the signed spec — D12: the close stores the counting rule, never the
61
+ // number, so a baseline frozen at signing would judge run 5 against run 1's tree.
62
+ //
63
+ // SEED WORKTREES are scoped to ONE `runDeclaredStages` call unless a caller
64
+ // hands in a shared holder. Cost, measured against safety and stated rather than
65
+ // assumed: at most one checkout per close invocation (M1 shares it across that
66
+ // close's stages), and none at all while the tree is verifiably at seed —
67
+ // precheck and preflight therefore pay nothing. A run-scoped holder would pay
68
+ // once instead of once-per-invocation, but `runPlan` has a dozen return paths and
69
+ // a leaked worktree outlives the process that made it.
70
+
71
+ import { runStage, makeSeedTrees, STOP_FAULTS, EXIT_RED } from './kinds.js';
72
+ import {
73
+ validateDeclaration, classGuards, genreOwnedEnvNames, ungroundedGenreEnv, GENRE_LANGUAGES, TYPES_GENRE,
74
+ VERDICT_CLASSES, LIVE_CLASSES,
75
+ } from './authoring.js';
76
+ import { stageGap } from './ralph.js';
77
+ import { isObj, isNonEmptyString } from './validate.js';
78
+
79
+ /** @typedef {{code: string, path: string, detail: string, [k: string]: any}} Red */
80
+
81
+ /** The genres a `closeDecl` may name. v1 admits exactly one (D13) — a genre
82
+ * classifier is designed the day there is a second genre to classify. */
83
+ export const DECLARED_GENRES = Object.freeze([TYPES_GENRE.name]);
84
+ /**
85
+ * The exact field set of a `closeDecl` — anything else is a smuggle channel, the
86
+ * same rule `STAGE_FIELDS` enforces on a command stage.
87
+ *
88
+ * `genreEnv` is the ARBITER's own record of what it injected (M3's
89
+ * `applyGenreEnv`), and it is here because the injection has to survive being
90
+ * re-read. The flow applies the genre's variable to every env-capable stage
91
+ * AFTER the model's form passed; from then on every re-validation — signing
92
+ * (`prepareSigning`), the job validator (`src/job.js`), the runner
93
+ * (`src/planrun.js`) — sees that key sitting in `params.env` and cannot, from
94
+ * the stage alone, tell our injection from a model-authored guess. Recording it
95
+ * once, in the SIGNED envelope, is what makes the comparison an EQUALITY rather
96
+ * than a presence flag (the same move `checkGuards` makes for guard params), and
97
+ * it is why the model never gets to author it: `authorCloseForJob` composes this
98
+ * field from the flow's own applied map, never from model output. Its keys are
99
+ * constrained to the names the genre OWNS, so it can never become an environment
100
+ * channel of its own.
101
+ */
102
+ export const CLOSE_DECL_FIELDS = Object.freeze(['genre', 'lang', 'stages', 'notes', 'genreEnv']);
103
+
104
+ /**
105
+ * The prefix M1 puts on EVERY gap line of a declared stage (its `ctx.gapKeep`).
106
+ * A literal, so the anchored pattern below is exact.
107
+ */
108
+ export const DECLARED_GAP_PREFIX = 'close: ';
109
+ /** The `gapKeep` PATTERN for a declared stage, DERIVED from the prefix so the
110
+ * two can never drift. Layer R's red-set and `boundGap`'s keep-block both read
111
+ * it; because every declared line carries the prefix, it designates the stage's
112
+ * whole judged output rather than a hand-picked slice of it. */
113
+ export const DECLARED_GAP_KEEP = `^${DECLARED_GAP_PREFIX.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}`;
114
+
115
+ /** The verdict class a DECLARED close may claim. Hard, and by CONSTRUCTION
116
+ * rather than by assertion: every live kind is a mechanical command whose
117
+ * measurement is the truth, and the two judgment kinds are locked out at the
118
+ * catalogue (M2) and absent from the executor (M1). Named as data so the job
119
+ * validator reads one hierarchy table rather than hard-coding a class. */
120
+ export const DECLARED_CLOSE_CLASSES = Object.freeze(['hard']);
121
+
122
+ /** @param {any} job @returns {boolean} */
123
+ export function isDeclaredClose(job) {
124
+ return isObj(job) && isObj(job.closeDecl) && Array.isArray(job.closeDecl.stages);
125
+ }
126
+
127
+ /**
128
+ * The declaration's stages as the RUNNER's stage descriptors: the declared
129
+ * `{name, kind, params}` plus the arbiter's own `gapKeep`.
130
+ *
131
+ * `offer` and `needs` pass through untouched when a declaration carries them
132
+ * (v1's schema does not let one), so `checkMenu`'s derivation is unchanged.
133
+ * @param {any} closeDecl
134
+ * @returns {any[]|null} null when this is not a declaration
135
+ */
136
+ export function declaredStages(closeDecl) {
137
+ if (!isObj(closeDecl) || !Array.isArray(closeDecl.stages)) return null;
138
+ return closeDecl.stages.map((s) => (isObj(s) ? { ...s, gapKeep: DECLARED_GAP_KEEP } : s));
139
+ }
140
+
141
+ // ── the spec-level gate ──────────────────────────────────────────────────────
142
+
143
+ /**
144
+ * Validate a `closeDecl` as a JOB SPEC field: the envelope (genre, language,
145
+ * notes) plus M2's full declaration validator underneath it.
146
+ *
147
+ * `listing` is what splits the two callers, and the split is deliberate:
148
+ * - the JOB VALIDATOR has no repository, so it passes nothing and gets
149
+ * `grounded: false` — every tree-independent rule runs, the path rule and
150
+ * the scoped-job derivation do not;
151
+ * - the RUNNER has the real seed, so it passes the real listing and gets
152
+ * `grounded: true`. It runs this BEFORE any stage and before any token, so
153
+ * the deferred half is proved rather than skipped.
154
+ * A caller that passes neither a listing nor `deferListing` gets M2's
155
+ * `listing-absent` red, unchanged: a validator handed nothing to check against
156
+ * must refuse, never report a declaration it never examined.
157
+ *
158
+ * THREE checks now ride on the listing, not two: the path rule, the scoped-job
159
+ * derivation, and the GROUNDING of a recorded `genreEnv` (a by-value comparison
160
+ * between two copies inside one hand-editable spec proves they agree, never that
161
+ * the arbiter wrote them — see `ungroundedGenreEnv`). All three are deferred at
162
+ * the job-spec gate and all three are re-run GROUNDED by the runner before any
163
+ * stage executes, so the deferral moves the check rather than dropping it. The
164
+ * grounded gates are `prepareSigning`'s gate 1b — where the signature is minted,
165
+ * so a forged env can never be signed in the first place — and `runPlan`'s
166
+ * pre-flight, which catches a spec edited after signing.
167
+ *
168
+ * `verdictType` is the class the USER picked, and it is REQUIRED here because
169
+ * the guard battery keys off it (PRD v1.57 §2): with no class in hand there is
170
+ * no battery, and M2 would be handed nothing to check D5 against. It arrives
171
+ * from the spec's sibling `verdictType` field on every caller — the job
172
+ * validator, the signing gate and the runner all hold the whole spec.
173
+ *
174
+ * @param {any} closeDecl
175
+ * @param {{at?: string, listing?: string[]|null, deferListing?: boolean,
176
+ * catalogue?: Record<string, any>, verdictType?: string|null}} [opts]
177
+ * @returns {{ok: boolean, reds: Red[], closeDecl: any, grounded: boolean,
178
+ * scoped: {scoped: boolean, via: string|null},
179
+ * ceiling: {class: string, kind: string, stage: string|null, locked: boolean}|null}}
180
+ */
181
+ export function validateCloseDecl(closeDecl, opts = {}) {
182
+ const { at = 'closeDecl', listing = null, catalogue, verdictType = null } = opts;
183
+ const deferListing = opts.deferListing === true;
184
+ /** @type {Red[]} */
185
+ const reds = [];
186
+ /** @type {(code: string, path: string, detail: string, extra?: object) => void} */
187
+ const red = (code, path, detail, extra = {}) => { reds.push({ code, path, detail, ...extra }); };
188
+ const bail = () => ({ ok: false, reds, closeDecl: null, grounded: false, scoped: { scoped: false, via: null }, ceiling: null });
189
+
190
+ // THE PICKED CLASS, before anything else, because the battery hangs off it.
191
+ // The path is the SPEC's own `verdictType` rather than a `closeDecl` child:
192
+ // the class is the user's separate answer and this red must point at the field
193
+ // they actually set, not at the artefact it invalidates.
194
+ //
195
+ // A locked class reds here as well as on job.js's counted `request-red`, and
196
+ // that is two axes rather than one problem twice: one COUNTS the demand for
197
+ // the class, this one says the close cannot be validated because the class it
198
+ // was picked under has no battery. It is the same split `langOk` already
199
+ // makes, and both bail for the same reason — a validator configured out of its
200
+ // own rule reports a declaration it never examined.
201
+ const picked = String(verdictType);
202
+ if (!VERDICT_CLASSES.includes(picked)) {
203
+ red('class-absent', 'verdictType', `the verdict class the user picked — one of ${VERDICT_CLASSES.join(' | ')}. `
204
+ + 'It selects this close\'s mandatory guard battery, which has no default and is never inferred');
205
+ return bail();
206
+ }
207
+ if (!LIVE_CLASSES.includes(picked)) {
208
+ red('class-battery-locked', 'verdictType', `"${picked}" is declared-but-locked: v1 builds a guard battery for `
209
+ + `${LIVE_CLASSES.join(', ')} only, and a close cannot be validated against a battery that does not exist — `
210
+ + 'validating it against another class\'s guards would sign a close nobody checked');
211
+ return bail();
212
+ }
213
+
214
+ if (!isObj(closeDecl)) {
215
+ red('invalid-value', at, 'an authored close DECLARATION: { genre, lang, stages: [...] }');
216
+ return bail();
217
+ }
218
+ for (const key of Object.keys(closeDecl)) {
219
+ if (!CLOSE_DECL_FIELDS.includes(key)) {
220
+ red('unknown-field', `${at}.${key}`, `not a closeDecl field (script bodies, seed refs and frozen thresholds all `
221
+ + `land here); fields: ${CLOSE_DECL_FIELDS.join(', ')}`);
222
+ }
223
+ }
224
+ if (!DECLARED_GENRES.includes(closeDecl.genre)) {
225
+ red('invalid-value', `${at}.genre`, `menu: ${DECLARED_GENRES.join('|')} — v1 admits exactly one genre (D13), and a `
226
+ + 'genre with no guard battery must refuse rather than author a close without the guards it cannot supply');
227
+ }
228
+ const langOk = GENRE_LANGUAGES.includes(closeDecl.lang);
229
+ if (!langOk) {
230
+ red('invalid-value', `${at}.lang`, `menu: ${GENRE_LANGUAGES.join('|')} — the language selects the genre's guard `
231
+ + 'battery and its environment, both of which are ours and neither of which has a default');
232
+ }
233
+ // `notes` is NOT checked here. It is M2's rule now (`validateDeclaration`),
234
+ // for the reason the divergence caused: this gate runs at SIGNING, and a rule
235
+ // that lives only at signing cannot be fed back as a revision — the run pays
236
+ // for a close, the authoring loop accepts it, and the job dies on a defect the
237
+ // model was never shown. One spelling, at the gate that can still revise, and
238
+ // the red still arrives here with this gate's own `at` prefix. It rides the
239
+ // `langOk` bail below like every other declaration-shape red: with no language
240
+ // there is no declaration to validate at all.
241
+ //
242
+ // Without a language there is no guard battery and no owned-env list, so M2
243
+ // would be handed nothing to check D5 against — and a validator configured out
244
+ // of its own rule reports a declaration it never examined. Refuse here instead.
245
+ if (!langOk) return bail();
246
+
247
+ // The recorded injection (see CLOSE_DECL_FIELDS). Constrained to the names the
248
+ // genre owns and to non-empty string values: an unowned name here would be an
249
+ // environment channel wearing the arbiter's coat, and an empty value is not the
250
+ // same instruction as an unset variable (`genreEnv` never ships one).
251
+ const owned = genreOwnedEnvNames(closeDecl.lang);
252
+ /** @type {Record<string, string>} */
253
+ const envInjected = {};
254
+ if (closeDecl.genreEnv !== undefined) {
255
+ if (!isObj(closeDecl.genreEnv)) {
256
+ red('invalid-value', `${at}.genreEnv`, 'the genre environment this close was authored under, as an object of '
257
+ + 'non-empty string values — it records what the ARBITER injected, and nothing else belongs in it');
258
+ } else {
259
+ for (const [name, value] of Object.entries(closeDecl.genreEnv)) {
260
+ if (!owned.includes(name)) {
261
+ red('invalid-value', `${at}.genreEnv.${name}`, `the ${closeDecl.lang} genre does not own ${name} — this field `
262
+ + `records the genre's own injection only (${owned.length ? owned.join(', ') : 'this genre owns none'}), `
263
+ + 'never an environment of its own');
264
+ } else if (!isNonEmptyString(value)) {
265
+ red('invalid-value', `${at}.genreEnv`, `${name} is recorded with no value — an empty variable is not the same `
266
+ + 'instruction as an unset one, and the genre never builds one');
267
+ } else envInjected[name] = value;
268
+ }
269
+ }
270
+ }
271
+ // GROUNDED, wherever there is a tree to ground against. The by-VALUE check
272
+ // above proves the envelope and the stage AGREE — and both copies sit in the
273
+ // same hand-editable spec, so agreement is free to a forger: `genreEnv` and
274
+ // `params.env` both reading `/tmp/attacker-stubs` is perfectly self-consistent
275
+ // and grades a checkout nobody signed. The seed tree is the thing the forger
276
+ // does not own, so every element of a recorded value must SELECT from the
277
+ // listing — the same trick hamr's path rule already uses, and an exact fit for
278
+ // what `applyGenreEnv` can produce (see `ungroundedGenreEnv`).
279
+ //
280
+ // It runs at both gates that hold a listing (`prepareSigning`'s grounded gate
281
+ // 1b and the runner's pre-flight re-validation) and cannot run at the deferred
282
+ // job-spec gate, which has no repository — that gate's deferral is already
283
+ // stated above, and the runner re-validates GROUNDED before any stage runs, so
284
+ // this is moved rather than skipped.
285
+ if (Array.isArray(listing) && listing.length > 0) {
286
+ for (const u of ungroundedGenreEnv(closeDecl.lang, envInjected, listing)) {
287
+ red('genre-env-ungrounded', `${at}.genreEnv.${u.name}`, `${u.name} is recorded as the genre's own injection but `
288
+ + `"${u.element}" matches nothing in the seed tree — the arbiter builds this value by joining source prefixes it `
289
+ + 'already filtered against the listing, so a path the tree does not contain is not one it can have written',
290
+ { name: u.name, element: u.element, resolved: u.resolved });
291
+ }
292
+ }
293
+
294
+ const v = validateDeclaration(closeDecl, {
295
+ ...(catalogue ? { catalogue } : {}),
296
+ // BOTH are forwarded, always. Forwarding only one would hide the case M2
297
+ // reds as `listing-conflict` — a caller that defers the tree-grounded half
298
+ // AND hands over a tree does not know which gate it is running, and silently
299
+ // honouring one of the two is how a signature ends up covering a check
300
+ // nobody performed.
301
+ listing,
302
+ ...(deferListing ? { deferListing: true } : {}),
303
+ guards: classGuards({ verdictType: picked, lang: closeDecl.lang }),
304
+ verdictType: picked,
305
+ envOwned: owned,
306
+ // POST-injection: an owned name is accepted only when its value is EXACTLY
307
+ // the recorded one. `{}` (nothing recorded) keeps the pre-injection rule, so
308
+ // a declaration that authored the variable itself is still refused.
309
+ envInjected,
310
+ });
311
+ for (const r of v.reds) reds.push({ ...r, path: r.path ? `${at}.${r.path}` : at });
312
+
313
+ const ok = reds.length === 0;
314
+ return {
315
+ ok,
316
+ reds,
317
+ closeDecl: ok ? { ...closeDecl, stages: v.declaration.stages } : null,
318
+ grounded: v.grounded,
319
+ scoped: v.scoped,
320
+ ceiling: v.ceiling,
321
+ };
322
+ }
323
+
324
+ // ── the execution bridge ─────────────────────────────────────────────────────
325
+
326
+ /**
327
+ * The genre's mandatory guard NAMES for a declaration — which stages are D5's
328
+ * guards and which are the job's own work. The seed-verdict read (D9.3) needs
329
+ * exactly this split: `changed-from-seed` is RED at its own seed by
330
+ * construction, so counting it as work would let a close with nothing to do
331
+ * pass the "something must be red at seed" gate.
332
+ * The battery keys off the CLASS the user picked (PRD v1.57 §2), so the class
333
+ * comes in beside the declaration. A class or a language with no battery hands
334
+ * back `[]` — this reads a split, it does not enforce one, and every caller
335
+ * reaches it only after `validateCloseDecl` has already refused both cases.
336
+ * @param {any} closeDecl @param {string} verdictType @returns {string[]}
337
+ */
338
+ export function guardNames(closeDecl, verdictType) {
339
+ if (!isObj(closeDecl) || !GENRE_LANGUAGES.includes(closeDecl.lang)) return [];
340
+ if (!LIVE_CLASSES.includes(String(verdictType))) return [];
341
+ return classGuards({ verdictType: String(verdictType), lang: closeDecl.lang }).map((g) => g.name);
342
+ }
343
+
344
+ /**
345
+ * One declared stage's result, in the ARBITER's verdict vocabulary.
346
+ *
347
+ * `notes` is the DIAGNOSTIC channel, and it exists because M1's kinds announce
348
+ * some things whichever way a stage lands: a declared env var that was dropped,
349
+ * the scope filter's own arithmetic. A red carries those out inside its gap; a
350
+ * green and an instrument stop had nowhere to put them and this function used to
351
+ * compute the text and discard it. A green measured over a filtered population
352
+ * is still a filtered number, and the operator reads why — checking the GREENS
353
+ * is doctrine, and an audit trail that only survives a red cannot do it.
354
+ *
355
+ * It is `notes` and NOT `.gap`, deliberately. Every consumer downstream guards
356
+ * with `if (gap)`, so a green carrying one would read as revision-worthy and a
357
+ * stop carrying one would look like a verdict it never rendered. Nothing routes
358
+ * on this field, nothing bounds on it, and no verdict moves because of it; it
359
+ * rides the spine records that already spread the whole verdict
360
+ * (`close-precheck`, `outer-close`, ralph's `close-verdict`).
361
+ * @param {any} r an M1 `StageResult`
362
+ * @param {(s: string) => string} redact
363
+ * @returns {{verdict: string, notes?: string, gap?: string, detail?: string,
364
+ * exitCode?: number, judgedCount?: number}} `notes` is absent, never empty —
365
+ * a field that is always there says nothing; a RED carries its diagnostics
366
+ * inside `gap` and so has none of its own.
367
+ */
368
+ function translate(r, redact) {
369
+ const gapText = redact((r.gapLines ?? []).join('\n'));
370
+ /** absent rather than empty: a field that is always there says nothing */
371
+ const notes = gapText ? { notes: gapText } : {};
372
+ if (r.verdict === 'green') return { verdict: 'satisfied', judgedCount: 1, ...notes };
373
+ if (r.verdict === 'red') {
374
+ return {
375
+ verdict: 'needs_revision',
376
+ // Never falsy: every consumer guards with `if (gap)`, and an empty red
377
+ // would silently kill gap feedback, the after-red hooks and the stall
378
+ // detector at once (the same rule `runClose` states for its own path).
379
+ gap: gapText || `(stage "${r.stage}" rendered a red with no output)`,
380
+ exitCode: r.exitCode ?? EXIT_RED,
381
+ judgedCount: 1,
382
+ };
383
+ }
384
+ // instrument-stop — NOT a verdict. The fault is a typed field stamped where the
385
+ // fault was observed, never sniffed out of the stop's prose. `detail` is the
386
+ // stop's own sentence; `notes` is the whole rendered output around it, which is
387
+ // where a measurement's already-computed arithmetic lives (`stopped`'s own
388
+ // `notes` argument) — the difference between hunting a parser bug and reading
389
+ // the scope.
390
+ return {
391
+ verdict: r.detail?.fault ?? STOP_FAULTS.FAILED,
392
+ detail: redact(String(r.detail?.stop ?? `stage "${r.stage}" rendered no judgment`)),
393
+ ...notes,
394
+ };
395
+ }
396
+
397
+ /**
398
+ * Run a DECLARED close (or a check's chain out of one) and return `runClose`'s
399
+ * verdict shape, so every consumer downstream — the precheck, ralph's judge, the
400
+ * `CLOSE_FAULTS` routing, the trend reader, Layer R — is unchanged.
401
+ *
402
+ * The extras a declared close can honestly add ride alongside rather than
403
+ * replacing anything: `declared: true` marks the shape, `value`/`baseline` carry
404
+ * the deciding stage's own measurement, and `trendValue` is the ONE number the
405
+ * trend reader may compare (see the header for why a higher-is-better stage
406
+ * donates none).
407
+ *
408
+ * @param {any[]} stages declared stage descriptors, already validated
409
+ * @param {(s: string) => string} [redact] the scrub, applied at THIS boundary
410
+ * @param {{timeoutMs?: number, cwd?: string, seedRef?: string,
411
+ * seedTrees?: any, gapCap?: number, maxBuffer?: number, baselineMode?: 'auto'|'worktree'}} [opts]
412
+ * @returns {Promise<any>}
413
+ */
414
+ export async function runDeclaredStages(stages, redact = (s) => s, opts = {}) {
415
+ const { timeoutMs, cwd, seedRef, seedTrees: shared, gapCap, maxBuffer, baselineMode } = opts;
416
+ if (!isNonEmptyString(cwd) || !isNonEmptyString(seedRef)) {
417
+ // Refuse rather than guess. A declared close measures against a SEED; a
418
+ // missing one is the close unable to run, not a red about the worker.
419
+ return {
420
+ verdict: STOP_FAULTS.FAILED,
421
+ detail: 'a declared close needs both the repository it judges (cwd) and the seed it measures against (seedRef) — '
422
+ + 'neither has a default, because a defaulted baseline grades a tree nobody chose',
423
+ stage: null,
424
+ stages: [],
425
+ declared: true,
426
+ };
427
+ }
428
+ const owned = !shared;
429
+ const seedTrees = shared ?? makeSeedTrees();
430
+ /** @type {any} */
431
+ const ctx = {
432
+ workdir: cwd,
433
+ seedRef,
434
+ gapKeep: DECLARED_GAP_PREFIX,
435
+ ...(timeoutMs !== undefined ? { timeoutMsDefault: timeoutMs } : {}),
436
+ ...(gapCap !== undefined ? { gapCap } : {}),
437
+ ...(maxBuffer !== undefined ? { maxBuffer } : {}),
438
+ ...(baselineMode !== undefined ? { baselineMode } : {}),
439
+ seedTrees,
440
+ };
441
+ try {
442
+ /** @type {any[]} */
443
+ const ran = [];
444
+ /** @type {any} */
445
+ let last = { verdict: 'satisfied', judgedCount: 1 };
446
+ for (const st of stages) {
447
+ const r = await runStage(st, ctx);
448
+ const t = translate(r, redact);
449
+ ran.push({
450
+ name: st?.name ?? null,
451
+ verdict: t.verdict,
452
+ ...(t.exitCode !== undefined ? { exitCode: t.exitCode } : {}),
453
+ ...(r.value !== null && r.value !== undefined ? { value: r.value } : {}),
454
+ ...(r.baseline !== null && r.baseline !== undefined ? { baseline: r.baseline } : {}),
455
+ // PER STAGE, because the summary below carries only the LAST translation:
456
+ // an earlier green's announcement would otherwise be lost behind the
457
+ // stage that decided.
458
+ ...(t.notes !== undefined ? { notes: t.notes } : {}),
459
+ });
460
+ last = t;
461
+ if (t.verdict !== 'satisfied') {
462
+ return {
463
+ ...t,
464
+ stage: st?.name ?? null,
465
+ stages: ran,
466
+ declared: true,
467
+ value: r.value ?? null,
468
+ baseline: r.baseline ?? null,
469
+ trendValue: trendValueOf(st, r),
470
+ // the same header the command path writes, from the SAME function —
471
+ // `src/trend.js` parses it to bucket the grade per stage
472
+ ...(t.gap ? { gap: stageGap(/** @type {string} */ (st?.name), t.gap) } : {}),
473
+ };
474
+ }
475
+ }
476
+ return { ...last, stages: ran, declared: true, stage: undefined };
477
+ } finally {
478
+ if (owned) await seedTrees.cleanup();
479
+ }
480
+ }
481
+
482
+ /**
483
+ * The ONE number the trend reader may compare, and the omissions are the point.
484
+ * `trend.js` reads improvement as `value < best`, so:
485
+ * - `lower-is-better` counts donate their value (a fault count falling IS
486
+ * convergence);
487
+ * - `higher-is-better` floors donate NOTHING. A test-count floor dropping is
488
+ * the run getting WORSE, and donating it would read as convergence and
489
+ * recommend a top-up on a dying run — the one direction trend.js names as
490
+ * dangerous. F6's answer applies: unknown, never a number that means the
491
+ * opposite of what it says.
492
+ * - a stage with no measurement at all donates nothing, for the same reason.
493
+ * The stage POSITION still travels either way, so "the run got further than it
494
+ * ever had" is readable on every declared close.
495
+ * @param {any} stage @param {any} r the M1 StageResult
496
+ * @returns {number|null}
497
+ */
498
+ function trendValueOf(stage, r) {
499
+ if (typeof r?.value !== 'number' || !Number.isFinite(r.value)) return null;
500
+ return stage?.params?.direction === 'lower-is-better' ? r.value : null;
501
+ }
502
+
503
+ /**
504
+ * The trend reader's input for ONE close verdict, whichever executor produced
505
+ * it. A command close hands over its gap and `readGrade` scrapes stage and count
506
+ * out of the text; a declared close already KNOWS both, so it says them instead
507
+ * of round-tripping them through prose it would then have to parse back.
508
+ *
509
+ * Byte-identical to the previous call for a command close: `{ gap }`, with
510
+ * `stage`/`value` left UNDEFINED so `trend.record` falls through to `readGrade`
511
+ * exactly as before. Passing `stage: null` instead would silently disable that
512
+ * fallback — undefined and null are different answers here.
513
+ * @param {any} v a `runClose`-shaped verdict
514
+ * @returns {{gap?: string, stage?: string|null, value?: number|null}}
515
+ */
516
+ export function closeGrade(v) {
517
+ if (v?.declared !== true) return { gap: v?.gap };
518
+ return { gap: v.gap, stage: v.stage ?? null, value: v.trendValue ?? null };
519
+ }
package/src/exits.js CHANGED
@@ -33,8 +33,14 @@ import { globToPrefix } from './validate.js';
33
33
  * `not ok`/`FAILED` names — the mechanical gap the F46 conversion mechanism
34
34
  * feeds the worker (F28 reintroduced). This cap is a defensive backstop above
35
35
  * boundGap's own envelope: a normal bounded gap passes through intact, and only
36
- * a pathological seam that returned an unbounded gap would ever be trimmed. */
37
- const CHECK_GAP_MAX = 12000;
36
+ * a pathological seam that returned an unbounded gap would ever be trimmed.
37
+ *
38
+ * Exported for the ONE reason `boundGap`/`GAP_KEEP_TRIM_MARKER` are (src/ralph.js):
39
+ * the replan brief re-bounds this same artifact one seam later (`closeGapBlock`), and
40
+ * a second hand-spelled ceiling there would be a second answer to "how big may a
41
+ * check gap be" — the two would drift on exactly the property that matters, whether
42
+ * the `not ok`/`FAILED` names survive. */
43
+ export const CHECK_GAP_MAX = 12000;
38
44
 
39
45
  /** @typedef {{ type: string, pass: boolean, detail?: string, fault?: string }} ExitResult
40
46
  * `fault` present means the INSTRUMENT could not judge (unwired snapshot/seam,
package/src/index.js CHANGED
@@ -62,3 +62,75 @@ export { renderListing, selectionPrompt } from './selection.js';
62
62
  // `runReuse`'s own entry.
63
63
  export { validateEnvelope, resolveTrySpec, resolveReuse, reuseSpecHash, selectBridge, runReuse, REUSE_GRADED_RED, readResume, resumeTreeGate } from './reuse.js';
64
64
  export { classifyIncidents, foldLedger, ledgerDeltas, updateLedger, LEDGER_CLASSES } from './ledger.js';
65
+ // THE WORK BRANCH (PRD v1.57 §3). `workBranchName` is exported so an operator runner can
66
+ // SHOW, before the approval gate, which branch the run will work on — the same reason the
67
+ // resolved per-try hash is printed there: a bound the human is agreeing to should not be
68
+ // discovered from the spine afterwards. `prepareWorkBranch` rides with it because a
69
+ // runner that resumes reads the branch off the dead spine and may want to verify it is
70
+ // still there before spending anything. The runner applies the rule itself either way —
71
+ // neither export is a way to opt out of it.
72
+ export { workBranchName, prepareWorkBranch, WORK_BRANCH_PREFIX, WORK_BRANCH_RE } from './workbranch.js';
73
+ // ── CLOSE AUTHORING v1 (gate 4) — the user declares what done means ──────────
74
+ // The public surface is settled ONCE, here, at M4 (M2's header flagged the
75
+ // naming collision and deferred it rather than exporting piecemeal).
76
+ //
77
+ // THE COLLISION, resolved: the kind executor's grading entry is exported as
78
+ // `runDeclaredClose`, and ralph's shipped `runClose(argv, redact, opts)` keeps
79
+ // its name. Two reasons, in order. (1) `runClose` is a DOCUMENTED adopter
80
+ // contract — it is in bareloop.context.md and in the exports map — and renaming
81
+ // a shipped function to make room for a new one is a breaking change bought for
82
+ // nothing. (2) The new name says what actually differs: one runs a DECLARATION,
83
+ // the other an argv. `runDeclaredClose` / `runDeclaredStages` / `runStages` read
84
+ // as a family, which is what they are.
85
+ //
86
+ // M1 — the kind executor. A runner needs `runStage`/`seedRead` to build its own
87
+ // gate (that is what `prepareSigning` does with them), and `seedAtHead` because
88
+ // D8's seed is READ at run start rather than typed into a spec.
89
+ export {
90
+ runDeclaredClose, runStage, seedRead, seedAtHead, seedListing, changedSet, makeSeedTrees,
91
+ LIVE_KINDS, STOP_FAULTS, JUDGED_MARKER, EXIT_GREEN, EXIT_RED, EXIT_STOP,
92
+ } from './kinds.js';
93
+ // M2 — what a declaration may SAY, and whether one said it legally. The
94
+ // catalogue and the genre are DATA an integrating UI renders (the kind menu, the
95
+ // guard batteries it must show the user under D5 and cannot let them remove).
96
+ export {
97
+ validateDeclaration, normalizeDeclaration, KIND_CATALOGUE, CATALOGUE_KINDS, CATALOGUE_LIVE_KINDS,
98
+ LOCKED_KINDS, TYPES_GENRE, TYPES_GENRE_TEMPLATE, GENRE_LANGUAGES, genreEnv, genreOwnedEnvNames,
99
+ DIRECTIONS, BASELINES, MAX_STAGES,
100
+ // the verdict-class surface (PRD v1.57 §1–§2): the radio's own menu, the guard
101
+ // battery keyed off it, and the ceiling that makes the pick a promise
102
+ VERDICT_CLASSES, LOCKED_CLASSES, LIVE_CLASSES, CLASS_BATTERIES, classGuards, closeCeiling,
103
+ } from './authoring.js';
104
+ // M3 — the looking half and the writing half. `authorClose` is the grounded
105
+ // loop; `runAuthorScout`/`buildSeedListing` are exported because a caller that
106
+ // already paid for a survey passes it back in rather than buying a second one.
107
+ export {
108
+ runAuthorScout, buildSeedListing, seedFileList, classifySurvey, AUTHOR_SCOUT_VERBS,
109
+ // the malformed-class retry ladder: the hardcoded ceiling, the typed causes it
110
+ // reads, and the closed set of causes that may fire it (2026-08-09, PRD v1.58)
111
+ SCOUT_ATTEMPTS, SURVEY_CAUSES, SCOUT_RETRY_CAUSES,
112
+ } from './authorscout.js';
113
+ /** the ONE scrubbed-persist boundary for a model's raw output, and its announced
114
+ * bound — exported because every future writer of a raw must reach for this one
115
+ * rather than spell a second scrub (PRD v1.58) */
116
+ export { scrubRaw, RAW_PERSIST_MAX, RAW_TRIM_MARKER } from './text.js';
117
+ export {
118
+ authorClose, authorPrompt, declarationSchema, makeLoopGenerate, MAX_REVISIONS,
119
+ QUESTION_SETS, GREEN_QUESTIONS, CLASS_STATEMENTS, questionsFor, requiredAnswersFor,
120
+ } from './authorflow.js';
121
+ // M4a — the runtime bridge. `validateCloseDecl` is the spec-level gate (the job
122
+ // validator's own branch calls it); `runDeclaredStages` is the executor seam a
123
+ // runner other than `runPlan` would need; `closeStagesOf` is the ONE staging
124
+ // every close consumer reads, widened to both fields.
125
+ export {
126
+ validateCloseDecl, runDeclaredStages, declaredStages, isDeclaredClose, guardNames, closeGrade,
127
+ DECLARED_GENRES, DECLARED_GAP_PREFIX, DECLARED_GAP_KEEP, DECLARED_CLOSE_CLASSES, CLOSE_DECL_FIELDS,
128
+ } from './declaredclose.js';
129
+ export { closeStagesOf } from './plan.js';
130
+ // M4b — the interview, the refusal, the composition, and D9's three gates.
131
+ // `prepareSigning` returns the resolved spec's HASH and the seed evidence; it
132
+ // never signs — the approvals array and the human's word are unchanged.
133
+ export {
134
+ runInterview, authorCloseForJob, assembleSpec, prepareSigning, refusalEvents, refuseLockedKind,
135
+ GENRE, REFUSAL_LIB, REFUSAL_CATEGORY, AUTHORED_SPEC_FIELDS,
136
+ } from './authorjob.js';