bareloop 0.11.0 → 0.13.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/kinds.js CHANGED
@@ -66,6 +66,7 @@ import { tmpdir } from 'node:os';
66
66
  import { join, sep } from 'node:path';
67
67
  import { globToPrefix, isNonEmptyString } from './validate.js';
68
68
  import { CLOSE_ENV_DENY } from './ralph.js';
69
+ import { runLocate, decide, validateCard, LOCATE_AXES, LOCATE_LABEL, JUDGE_MODEL } from './judged.js';
69
70
 
70
71
  /** the close's two judgment exits — a stage's verdict, in the shape the
71
72
  * hand-written closes already spoke (exit code is truth) */
@@ -132,18 +133,46 @@ export const STOP_FAULTS = Object.freeze({
132
133
 
133
134
  export const AGGREGATES = Object.freeze(['first', 'sum']);
134
135
  export const SIGNS = Object.freeze([1, -1]);
135
- /** every kind this executor implements. `judged-floor` / `human-confirms`
136
- * are catalogue entries and are deliberately absent here: declaring one is
137
- * counted demand (the request-red path), and reaching one at runtime is a
138
- * stop — never a red, and never a silent pass. */
139
- export const LIVE_KINDS = Object.freeze(['command-exit', 'count-not-worse', 'pattern-absent-in-diff', 'files-changed', 'human-confirms']);
136
+ /** every kind this executor implements. It is asserted EQUAL to the catalogue's
137
+ * own live half by the suite, both directions: a kind the catalogue offers and
138
+ * this executor cannot run is a close that stops at runtime, and a kind this
139
+ * executor runs that the catalogue never names is a capability nothing can
140
+ * reach. `judged-floor` joined at softgreen module 2 — the runner and the
141
+ * catalogue unlock in ONE commit for exactly that reason. */
142
+ export const LIVE_KINDS = Object.freeze(['command-exit', 'count-not-worse', 'pattern-absent-in-diff', 'files-changed', 'human-confirms', 'judged-floor']);
143
+ /**
144
+ * The judged stage's ladder: ONE attempt plus ONE retry, and no more.
145
+ *
146
+ * The POC measured a malformed emission roughly 1 in 6, which is exactly the
147
+ * shape a single retry buys down and a longer ladder buys nothing more of — each
148
+ * further attempt is another paid call against the same unchanged artifact and
149
+ * the same unchanged prompt. It is a CEILING the arbiter owns, never a knob a
150
+ * declaration can widen: a stage that could re-ask a model forever is a stage
151
+ * that can spend a budget without ever rendering a verdict.
152
+ *
153
+ * A `pricing-red` is never retried at all (see `runJudgedFloor`): a retry of an
154
+ * unpriced call only buys more spend nobody can see (F6).
155
+ */
156
+ export const JUDGE_ATTEMPTS = 2;
157
+ /**
158
+ * The ceiling on how many artifacts ONE judged stage may buy a judgement of —
159
+ * the sibling of `MAX_TERMS` and `MAX_STAGES`, and here for the same reason: a
160
+ * runaway list is a bill, not a red. Enforced by the catalogue's validator
161
+ * before any token, so a declaration that names more is refused at the gate
162
+ * rather than discovered at the invoice.
163
+ */
164
+ export const MAX_JUDGED_PATHS = 8;
140
165
  /** RULING 8 — the stages the seed-verdict read (D12) must NOT run. A judged
141
166
  * number at seed is unstable and a person at seed is an interview, not a
142
167
  * measurement; their bar comes from calibration (soft-green) or from the person
143
168
  * themselves (hitl), never from the seed. The skip is RECORDED by name rather
144
169
  * than taken in silence (F59: absent is not empty), so `seedRead` still returns
145
170
  * one row per declared stage. */
146
- export const SEED_EXEMPT_KINDS = Object.freeze(['judged-floor', 'human-confirms']);
171
+ /** the one kind a MODEL renders. Named once for the same reason `HUMAN_KIND` is:
172
+ * the authoring pipeline, the card, the catalogue and the close each key a
173
+ * decision on it, and five literals is how they come to disagree. */
174
+ export const JUDGED_FLOOR_KIND = 'judged-floor';
175
+ export const SEED_EXEMPT_KINDS = Object.freeze([JUDGED_FLOOR_KIND, 'human-confirms']);
147
176
  /** the one kind a PERSON renders — named once, because the runner keys three
148
177
  * decisions on it (is there a door to answer, which stage paused, whose ruling
149
178
  * this is) and three literals is how they come to disagree */
@@ -160,17 +189,92 @@ export const HUMAN_KIND = 'human-confirms';
160
189
  * to make one. */
161
190
  export const HUMAN_DECISIONS = Object.freeze(['accept', 'rerun', 'pause']);
162
191
 
192
+ // ── THE REVIEW DOOR's vocabulary (softgreen module 8, PRD v1.71 §3) ──────────
193
+ //
194
+ // The pause machinery re-homed: the same three doors, one level out — from a
195
+ // stage INSIDE the close to the door at the END of a run. The vocabulary lives
196
+ // here, beside the doors themselves, because `src/planrun.js` needs it to OPEN a
197
+ // door and `src/reviewdoor.js` needs it to ANSWER one, and a constant imported
198
+ // through the answering module would drag the registry (and `runJob` behind it)
199
+ // into the run's own import graph.
200
+
201
+ /** the door's record type on the spine. It is a DISPOSITION, never a verdict:
202
+ * hamr's law, verbatim — *"it's important not to change the loop self verdict"*.
203
+ * The close mints the verdict before this is written, and nothing downstream of
204
+ * this record may change what the ledger recorded. */
205
+ export const REVIEW_DOOR = 'review-door';
206
+
207
+ /** the terminals a door opens on: the verdict-bearing ones, and only those. A red,
208
+ * a casualty and a governance halt are not offered to a person as merchandise —
209
+ * they are already decision-ready in their own right, with their own levers. */
210
+ export const DOOR_OPEN_OUTCOMES = Object.freeze(['green', 'already-green']);
211
+
212
+ /** the classes whose door opens WITHOUT being asked for. Soft-green is here
213
+ * because the judge is young: its green is quarantined at mint (module 6) and a
214
+ * person's `accept` is the only thing that releases the credit, so a run that
215
+ * never offered the door would hold its own credit forever.
216
+ *
217
+ * `green` is deliberately ABSENT, and that is the one operator-facing default in
218
+ * this module: a green-class run behaves exactly as it always has unless the
219
+ * runner asks for the door (`reviewDoor: true`, `--review-door`). PRD v1.71 §3
220
+ * says *"the door at the end of EVERY run"* and also that a green door is
221
+ * NON-BLOCKING, and the two readings differ only in whether an unasked-for door
222
+ * appears on an existing job's spine. Flipping it is one line — adding `'green'`
223
+ * here — and it is hamr's to flip, not this module's to assume. A constant, never
224
+ * a threshold; widening it is arbiter territory. */
225
+ export const REVIEW_DOOR_CLASSES = Object.freeze(['soft-green']);
226
+
227
+ /**
228
+ * Does this run end at a review door? The operator's flag WINS in both
229
+ * directions and is never inferred: `true` opens it for any class, `false` shuts
230
+ * it for any class, and `null`/absent is the class default above.
231
+ * @param {any} job the signed spec (its `verdictType` is the class)
232
+ * @param {boolean|null} [reviewDoor] the operator's explicit choice, or null
233
+ * @returns {boolean}
234
+ */
235
+ export function doorOpens(job, reviewDoor = null) {
236
+ if (reviewDoor === true) return true;
237
+ if (reviewDoor === false) return false;
238
+ return REVIEW_DOOR_CLASSES.includes(job?.verdictType ?? 'green');
239
+ }
240
+
241
+ /**
242
+ * The stages an `accept` re-runs: the MECHANICAL ones, and never a judged floor
243
+ * or a person (`SEED_EXEMPT_KINDS`, ruling 8's set, for the same reason it was
244
+ * drawn — a machine may re-prove a machine's reading, and may not re-prove a
245
+ * judgment). hamr's N4 ruling: an accept re-runs the mechanical stages, because a
246
+ * door lives 60 days and nothing freezes a tree.
247
+ * @param {any[]|null|undefined} stages
248
+ * @returns {any[]}
249
+ */
250
+ export function mechanicalStages(stages) {
251
+ return (Array.isArray(stages) ? stages : []).filter((s) => !SEED_EXEMPT_KINDS.includes(s?.kind));
252
+ }
253
+
163
254
  /**
164
255
  * @typedef {{code: string, path: string, detail: string}} Red
165
256
  * @typedef {{workdir: string, seedRef: string, gapKeep: string,
166
257
  * timeoutMsDefault?: number, baselineMode?: 'auto'|'worktree',
167
258
  * gapCap?: number, maxBuffer?: number, seedTrees?: SeedTrees,
168
259
  * humanRuling?: {decision: string, text?: string|null}|null,
260
+ * judgeLoop?: (o: {system: string}) => any,
261
+ * onJudgeCost?: (c: {stage: string|null, kind: string|null, path: string, attempt: number,
262
+ * label: string, model: string, costUsd: number|null, unpricedRounds: number}) => void,
169
263
  * onStage?: (row: {stage: string|null, kind: string|null, verdict: string, durationMs: number}) => void}} Ctx
170
264
  * The arbiter's half of a stage run. Every field here is operator/runner
171
265
  * territory: a declaration parameterises kinds, never the contracts.
172
266
  * `humanRuling` is the SIGNER's answer at a hitl pause, carried in from the
173
267
  * runner — never authored, never defaulted, and absent on every ordinary run.
268
+ * `judgeLoop` is the PAID seam the judged stage runs its locate call through, and
269
+ * it is operator territory for the same reason the worker's provider is: this
270
+ * module owns no provider and never picks one, and the judge tier is PINNED
271
+ * (`JUDGE_MODEL`) rather than chosen. Absent is not a fall-back to some other
272
+ * model — the stage stops as a wiring gap, the same answer `runPlan` gives a
273
+ * native job with no native factory.
274
+ * `onJudgeCost` is the METER, and it is the only reason a judged stage can be
275
+ * funded honestly: this executor cannot reach the run's ledger, so every locate
276
+ * call's cost is reported OUT through it, once per call, on every route including
277
+ * the red ones — a paid call that leaves no meter record is F12 in a judge's coat.
174
278
  * `onStage` is a REPORTING seam and nothing else: the seed read spawns a real
175
279
  * toolchain per stage (a suite is the slow one) and a whole read is one opaque
176
280
  * await, so the shell gets told as each stage lands. It decides nothing, it is
@@ -1405,6 +1509,79 @@ export function normalizeHumanRuling(ruling) {
1405
1509
  return { ok: true, ruling: { decision, text: null }, why: null };
1406
1510
  }
1407
1511
 
1512
+ /**
1513
+ * WHICH ANSWER IS THIS LEG ACTING ON — the fresh one, or the one the checkpoint
1514
+ * held? F102's cure needs one seam that can say, because two callers ask the same
1515
+ * question (`runJob` declares the fold on `job-start` before `runPlan` reads the
1516
+ * ruling at all), and two spellings of "is this a fresh engagement" is how the
1517
+ * record and the clock come to disagree about the same leg.
1518
+ *
1519
+ * THE AMBIGUITY IS REFUSED, NEVER MERGED. A checkpoint that holds a decision and a
1520
+ * command line that carries another are two answers to one question. Merging them
1521
+ * picks a winner nobody chose; overriding silently discards words a person typed
1522
+ * (the F98/F28 class at a process boundary). So it comes back as a refusal that
1523
+ * NAMES the held decision, and the operator resolves it — by answering the held
1524
+ * one, or by starting a fresh run against the tree as it stands.
1525
+ *
1526
+ * `fresh` is F103's half: a `rerun` is a FRESH ENGAGEMENT (design record §3.4 —
1527
+ * *"redo/rerun comes with new authoring for money+time and keeps accounting of
1528
+ * this far and this session separate counters"*), and nothing else is. An `accept`
1529
+ * commissions no work and a `pause` runs nothing, so neither opens one.
1530
+ *
1531
+ * A REVIEW DOOR's rerun (module 8) is the SAME fact arriving one level out, and it
1532
+ * is folded in here rather than beside the clock, because "is this a fresh
1533
+ * engagement" gets ONE spelling — a second one is how a record and a clock come to
1534
+ * disagree about the same leg, which is the very thing this resolver exists to
1535
+ * prevent. It is deliberately NOT read as a ruling: a `humanRuling` answers a
1536
+ * close's human STAGE and is refused for a close that has none, so the door's words
1537
+ * stay in their own parameter and only this reading is shared.
1538
+ *
1539
+ * PRECEDENCE, stated because both CAN be handed to one leg: a door rerun makes the
1540
+ * engagement fresh whatever the ruling says, and the two are OR'd, never merged.
1541
+ * Freshness is the FAIL-SAFE direction — a fresh clock costs a leg nothing, while
1542
+ * inheriting a dead leg's wall is F103's measured, structurally-impossible
1543
+ * engagement. The door's own validation (empty words are refused) stays where it
1544
+ * is, at the seam that accepts it.
1545
+ * @param {any} fresh the answer arriving on this invocation (`--decide`), or null
1546
+ * @param {any} held the answer the CHECKPOINT carries (`readResume`'s
1547
+ * `restart.pendingDecision`), or null. `receivedAt` is when the PERSON said it —
1548
+ * carried through so the resumed leg records the receipt, not its own re-reading.
1549
+ * @param {any} [doorRerun] MODULE 8 — the review door's rerun, when this leg IS the
1550
+ * fresh engagement a signer commissioned at a previous run's door. Presence is the
1551
+ * whole of what is read here; the words are the accepting seam's business.
1552
+ * @returns {{ok: boolean, why: string|null, ruling: {decision: string, text: string|null}|null,
1553
+ * source: 'operator'|'checkpoint'|null, receivedAt: string|null, fresh: boolean}}
1554
+ */
1555
+ export function resolveHumanRuling(fresh, held, doorRerun = null) {
1556
+ const has = (/** @type {any} */ v) => v !== null && v !== undefined;
1557
+ const atDoor = has(doorRerun);
1558
+ const none = { ruling: null, source: /** @type {any} */ (null), receivedAt: null, fresh: atDoor };
1559
+ if (has(fresh) && has(held)) {
1560
+ const d = typeof held === 'object' && !Array.isArray(held) ? String(/** @type {any} */ (held).decision) : String(held);
1561
+ return {
1562
+ ok: false,
1563
+ why: `this checkpoint already holds a "${d}" decision that no round was ever bought for, and a second answer `
1564
+ + 'arrived with this invocation. Two answers to one question is ambiguity, not a merge: resume with no '
1565
+ + 'decision to act on the held one, or start a fresh run against the tree as it stands.',
1566
+ ...none,
1567
+ };
1568
+ }
1569
+ const raw = has(fresh) ? fresh : held;
1570
+ const norm = normalizeHumanRuling(raw ?? null);
1571
+ if (!norm.ok) return { ok: false, why: norm.why, ...none };
1572
+ if (norm.ruling === null) return { ok: true, why: null, ...none };
1573
+ const at = has(held) ? /** @type {any} */ (held).receivedAt : null;
1574
+ return {
1575
+ ok: true,
1576
+ why: null,
1577
+ ruling: norm.ruling,
1578
+ source: has(fresh) ? 'operator' : 'checkpoint',
1579
+ receivedAt: typeof at === 'string' && at.length > 0 ? at : null,
1580
+ // OR'd, never merged: either door the person came through commissions work
1581
+ fresh: atDoor || norm.ruling.decision === 'rerun',
1582
+ };
1583
+ }
1584
+
1408
1585
  /**
1409
1586
  * THE HUMAN STAGE (N4 slice 1) — the one kind that measures nothing.
1410
1587
  *
@@ -1464,6 +1641,212 @@ async function runHumanConfirms(stage, ctx) {
1464
1641
  {}, STOP_FAULTS.CRASHED);
1465
1642
  }
1466
1643
 
1644
+ /**
1645
+ * THE JUDGED FLOOR (softgreen module 2) — the one kind that BUYS its measurement.
1646
+ *
1647
+ * It is `src/judged.js`'s two halves wired into the close, and the split is the
1648
+ * whole safety argument: LOCATE is a pinned, toolless model asked for facts with
1649
+ * addresses over the AUTHORITATIVE artifact, and DECIDE is a pure arbiter-owned
1650
+ * function with no model in it. The judge never renders a verdict; this runner
1651
+ * renders it, from an owned rulebook, over quotes the artifact must contain.
1652
+ *
1653
+ * FOUR ROUTES OUT, and which is which is the load-bearing part:
1654
+ *
1655
+ * - GREEN / RED come from `decide()` alone. A red is CONTENT — facts in hand,
1656
+ * and a card item those facts do not satisfy (or cannot be sure about, since
1657
+ * unsure is red). That is evidence about the tree.
1658
+ * - AN INSTRUMENT STOP is everything else: a card this arbiter cannot read, an
1659
+ * artifact that will not open, no judge seam wired, and — after the ladder —
1660
+ * a judge whose emission never parsed or whose call never came back.
1661
+ * Contract (a) names "unreadable or unparseable required output" as a stop in
1662
+ * so many words, and the reason is the casualty rule: our judge producing
1663
+ * garbage says nothing whatsoever about the worker, and grading a tree red on
1664
+ * it would be manufactured evidence (F45).
1665
+ * - A `pricing-red` locate is that same stop with the retry REFUSED: an
1666
+ * unpriced call cannot be summed, and buying a second one only adds more
1667
+ * spend nobody can see (F6).
1668
+ *
1669
+ * The gap is ITEMIZED (ruling 7, F38/F39): named card items, per-file, with the
1670
+ * function names and the instrument's own quotes — never one paragraph. It rides
1671
+ * the same `Gap` bound as every other kind, so the trim is announced.
1672
+ *
1673
+ * EVERY path is judged, not just up to the first red, for the same reason
1674
+ * `decide()` grades every item: calibration reads itemized reds, and a stage that
1675
+ * short-circuits reports the first thing it found rather than what is true.
1676
+ * `firstRed` still names the deciding item in DECLARED order, so the verdict's
1677
+ * reason is stable against whatever order a model emitted anything in.
1678
+ *
1679
+ * NOT WIRED, AND NAMED RATHER THAN SILENTLY SKIPPED — `judgeToAnnotation` →
1680
+ * `gate.annotate` (the slice-2 obligation list, item 4). Two independent reasons,
1681
+ * either of which alone would defer it:
1682
+ * (1) THE ADAPTER TAKES A VERDICT. bare-agent's `judgeToAnnotation` maps a
1683
+ * `judge()` verdict (`{verdict, where:{field, stated, returned}}`) onto the
1684
+ * annotation shape, and this pipe deliberately never buys a verdict from a
1685
+ * model — LOCATE returns facts, and the verdict is `decide()`'s. Feeding it
1686
+ * a synthesised verdict would annotate the gate with a judgement no judge
1687
+ * made, which is the one thing the locate-not-verdict split exists to stop.
1688
+ * (2) THERE IS NO GATE HERE. A Gate is built per WORKER step (`mkWorker`,
1689
+ * src/planrun.js) and is gone by the time the close runs; the close is
1690
+ * deliberately outside every worker's fence. Annotating "the arbiter's own
1691
+ * gate" needs a run-scoped gate that does not exist today, and inventing
1692
+ * one to carry an annotation would be plumbing built for its own sake.
1693
+ * The facts themselves are NOT lost meanwhile: every per-path reading (verdict,
1694
+ * itemized reds, the ladder, the scrubbed raw) rides `detail.perPath`.
1695
+ * @param {any} stage @param {Ctx} ctx @returns {Promise<StageResult>}
1696
+ */
1697
+ async function runJudgedFloor(stage, ctx) {
1698
+ const p = stage.params;
1699
+ // 1. the CARD, through the one spelling (`validateCard`). A card this arbiter
1700
+ // cannot read is a broken close, never a red about the tree — and it is
1701
+ // checked before the seam and before the artifact, because it costs nothing.
1702
+ const cv = validateCard(p?.card);
1703
+ if (!cv.ok) {
1704
+ return stopped(stage, ctx, `INSTRUMENT: stage "${stage.name}" carries a rubric card this arbiter cannot read: ${cv.reds.join('; ')}`,
1705
+ {}, STOP_FAULTS.CRASHED);
1706
+ }
1707
+ const paths = Array.isArray(p?.paths) ? p.paths.filter(isNonEmptyString) : [];
1708
+ if (paths.length === 0) {
1709
+ return stopped(stage, ctx, `INSTRUMENT: stage "${stage.name}" names no artifact to judge — a judged stage with no `
1710
+ + 'paths judges nothing, and nothing is not a pass', {}, STOP_FAULTS.CRASHED);
1711
+ }
1712
+ if (paths.length > MAX_JUDGED_PATHS) {
1713
+ return stopped(stage, ctx, `INSTRUMENT: stage "${stage.name}" names ${paths.length} artifacts, over the ceiling of `
1714
+ + `${MAX_JUDGED_PATHS} — a judged stage buys one call per artifact, and a runaway list is a bill`, {}, STOP_FAULTS.CRASHED);
1715
+ }
1716
+ // 1b. THE TIER THE SIGNED SET WAS GRADED BY, against the tier this run would buy.
1717
+ // `JUDGE_MODEL` has always SAID a bump forces a full recalibration; this is the
1718
+ // detector, because a rule with no wired detector is prose (F45). The stamp comes
1719
+ // off the SIGNED calibration (`calibration.judgeModel`, carried across by
1720
+ // `declaredStages`), so a mismatch means exactly one thing: the pin moved under a
1721
+ // signature, and the floor standing behind this stage was certified by a model
1722
+ // nobody is about to call. That is a FAILED wiring gap and not a crash — the same
1723
+ // fault, and for the same reason, as the absent seam below: nothing is broken,
1724
+ // something was never re-done. It stops rather than degrading in either direction,
1725
+ // because grading on the new tier would mint a verdict against an uncalibrated
1726
+ // floor and grading on the old one is not on offer (the pin is a constant).
1727
+ // AN ABSENT STAMP IS NOT CHECKED HERE, deliberately and for the reason the SET's
1728
+ // own presence is not checked in `validateCloseDecl`: a spec that judges cannot
1729
+ // reach a run without a calibration (the signing gate, module 5) or without a
1730
+ // model on it (`validateCloseDecl`), so the runtime question is only ever WHICH
1731
+ // model — and a stage descriptor handed straight to `runStage` by an adopter or a
1732
+ // test is not a signed spec making a claim about a tier.
1733
+ if (isNonEmptyString(stage.calibrationJudgeModel) && stage.calibrationJudgeModel !== JUDGE_MODEL) {
1734
+ return stopped(stage, ctx, `INSTRUMENT: stage "${stage.name}" would grade with ${JUDGE_MODEL}, and the calibration `
1735
+ + `set signed with this close was graded by ${stage.calibrationJudgeModel} — the judge tier moved under the `
1736
+ + 'signature. A floor is worth exactly the judge that certified it, so this stops rather than grading against a '
1737
+ + 'floor nobody calibrated: re-sign the spec with the new tier and re-run the calibration gate (recalibrate)',
1738
+ { signedJudgeModel: stage.calibrationJudgeModel, judgeModel: JUDGE_MODEL }, STOP_FAULTS.FAILED);
1739
+ }
1740
+ if (typeof ctx.judgeLoop !== 'function') {
1741
+ return stopped(stage, ctx, `INSTRUMENT: stage "${stage.name}" is a judged stage and this run wired no judge seam `
1742
+ + `(ctx.judgeLoop) — an adopter wiring gap, never a silent fall-back: the judge is ${JUDGE_MODEL}, pinned, and a `
1743
+ + 'stage that quietly graded on whatever model was lying around would be a floor nobody calibrated',
1744
+ {}, STOP_FAULTS.FAILED);
1745
+ }
1746
+ const loopFactory = /** @type {(o: {system: string}) => any} */ (ctx.judgeLoop);
1747
+
1748
+ const gap = new Gap(ctx.gapKeep, ctx.gapCap);
1749
+ /** @type {any[]} */
1750
+ const perPath = [];
1751
+ /** @type {{path: string, rule: string, text: string, reds: any[]}[]} */
1752
+ const redItems = [];
1753
+ /** the reds that name NO card item: `decide()`'s unsure routes (no usable facts,
1754
+ * an empty function list). They are the fail-safe itself, so the verdict is read
1755
+ * off the per-path verdicts and NEVER off `redItems` — a red with nothing itemized
1756
+ * under it is exactly the shape that would otherwise fall through to green. */
1757
+ /** @type {{path: string, reason: string}[]} */
1758
+ const unsure = [];
1759
+ let redFiles = 0;
1760
+
1761
+ for (const rel of paths) {
1762
+ let artifactText;
1763
+ try {
1764
+ artifactText = await readFile(join(ctx.workdir, rel), 'utf8');
1765
+ } catch (e) {
1766
+ return stopped(stage, ctx, `INSTRUMENT: stage "${stage.name}" could not read the artifact it judges (${rel}): `
1767
+ + `${String(/** @type {any} */ (e)?.message ?? e)}`, { path: rel }, STOP_FAULTS.CRASHED);
1768
+ }
1769
+ if (artifactText.trim() === '') {
1770
+ return stopped(stage, ctx, `INSTRUMENT: stage "${stage.name}"'s artifact ${rel} is empty — an empty artifact `
1771
+ + 'yields no facts, and no facts is unknown, never a pass', { path: rel }, STOP_FAULTS.CRASHED);
1772
+ }
1773
+
1774
+ /** @type {any} */
1775
+ let last = null;
1776
+ /** @type {any[]} */
1777
+ const tries = [];
1778
+ for (let attempt = 1; attempt <= JUDGE_ATTEMPTS; attempt += 1) {
1779
+ last = await runLocate({
1780
+ artifactText,
1781
+ card: p.card,
1782
+ loopFactory,
1783
+ attempt,
1784
+ onCost: (c) => {
1785
+ if (typeof ctx.onJudgeCost !== 'function') return;
1786
+ ctx.onJudgeCost({
1787
+ stage: stage?.name ?? null, kind: stage?.kind ?? null, path: rel, attempt,
1788
+ label: LOCATE_LABEL, model: JUDGE_MODEL, costUsd: c.costUsd, unpricedRounds: c.unpricedRounds,
1789
+ });
1790
+ },
1791
+ });
1792
+ tries.push({ attempt, ok: last.ok, axis: last.red?.axis ?? null, costUsd: last.costUsd, truncated: last.truncated, parseError: last.parseError });
1793
+ if (last.ok) break;
1794
+ // the ONE non-retryable axis, and it outranks the ladder: a retry of an
1795
+ // unpriced call buys more spend nobody can see (F6).
1796
+ if (last.red.axis === LOCATE_AXES.PRICING) break;
1797
+ }
1798
+ if (!last.ok) {
1799
+ return stopped(stage, ctx, `INSTRUMENT: stage "${stage.name}"'s judge returned no usable facts for ${rel} after `
1800
+ + `${tries.length} attempt(s) [${last.red.axis}]: ${last.red.detail}`,
1801
+ { path: rel, axis: last.red.axis, attempts: tries }, STOP_FAULTS.CRASHED);
1802
+ }
1803
+
1804
+ const d = decide(last.facts, p.card, { artifactText });
1805
+ perPath.push({ path: rel, verdict: d.verdict, firstRed: d.firstRed, attempts: tries, raw: last.raw, items: d.items });
1806
+ if (d.verdict === 'red') {
1807
+ redFiles += 1;
1808
+ for (const it of d.items) if (!it.ok) redItems.push({ path: rel, rule: it.rule, text: it.text, reds: it.reds });
1809
+ if (!d.items.some((it) => !it.ok)) unsure.push({ path: rel, reason: String(d.reason ?? 'unsure, and unsure is red') });
1810
+ }
1811
+ }
1812
+
1813
+ const red = redFiles > 0;
1814
+ if (red) {
1815
+ // FIRST-RED-WINS in the CARD's own signed order, across every artifact: the
1816
+ // card is the arbiter's order, and the file loop's order is the declaration's.
1817
+ const order = p.card.items.map((/** @type {any} */ it) => it.rule);
1818
+ const firstRed = order.find((/** @type {string} */ r) => redItems.some((x) => x.rule === r))
1819
+ ?? (redItems[0]?.rule ?? 'unsure');
1820
+ gap.push(`${stage.name}: the judged floor is not met — ${redItems.length} card item(s) red across ${redFiles} of `
1821
+ + `${paths.length} artifact(s); first red: ${firstRed}`);
1822
+ for (const u of unsure) gap.push(` ${u.path}: ${u.reason}`);
1823
+ for (const it of redItems) {
1824
+ gap.push(` [${it.rule}] ${it.text}`);
1825
+ // the file, the function and the quote are all things the INSTRUMENT itself
1826
+ // reported (contract (b) bars naming a culprit it did not) — and a red with
1827
+ // no address is the semantic genre F38 measured as inert
1828
+ for (const r of it.reds) {
1829
+ gap.push(` ${it.path}: ${r.fn} — ${r.why}`);
1830
+ if (r.quote) gap.push(` > ${String(r.quote).split('\n')[0].trim().slice(0, 200)}`);
1831
+ }
1832
+ }
1833
+ }
1834
+ return result(stage, {
1835
+ verdict: red ? 'red' : 'green',
1836
+ exitCode: red ? EXIT_RED : EXIT_GREEN,
1837
+ gapLines: gap.render(),
1838
+ detail: {
1839
+ model: JUDGE_MODEL,
1840
+ card: p.card.items.map((/** @type {any} */ it) => it.rule),
1841
+ paths,
1842
+ perPath,
1843
+ redItems: redItems.length,
1844
+ unsure,
1845
+ redFiles,
1846
+ },
1847
+ });
1848
+ }
1849
+
1467
1850
  /** @type {Record<string, (stage: any, ctx: Ctx) => Promise<StageResult>>} */
1468
1851
  const RUNNERS = {
1469
1852
  'command-exit': runCommandExit,
@@ -1471,6 +1854,7 @@ const RUNNERS = {
1471
1854
  'pattern-absent-in-diff': runPatternAbsentInDiff,
1472
1855
  'files-changed': runFilesChanged,
1473
1856
  'human-confirms': runHumanConfirms,
1857
+ 'judged-floor': runJudgedFloor,
1474
1858
  };
1475
1859
 
1476
1860
  /**
package/src/ledger.js CHANGED
@@ -404,3 +404,105 @@ export function updateLedger({ ledgerFile, spineFiles }) {
404
404
  });
405
405
  return { appended, fold: foldLedger(rows.concat(appended)) };
406
406
  }
407
+
408
+ // ── BA-21 pricing provenance: the READ side (REPORTING ONLY) ─────────────────
409
+ // bare-agent >=0.37 rides `rateSource` beside `pricing` on every metering payload,
410
+ // and the plan flow forwards it verbatim onto `worker-round`/`judge-round`/`worker-turn`
411
+ // (`rateSourceFields`, src/planrun.js — the write side).
412
+ //
413
+ // NAMED rather than papered over: `judge-round`'s own payload (src/kinds.js's
414
+ // `onJudgeCost({...})`) does not carry `rateSource` yet, so every judge round reads
415
+ // UNKNOWN provenance today — correctly, by the same rule that governs the archive, and
416
+ // the day that payload forwards the field the emit site already spreads it.
417
+ //
418
+ // `pricing` is UNCHANGED and still strictly two-valued ('priced'|'unpriced'). Nothing
419
+ // below reads it, nothing below decides anything, and no new escalation category exists:
420
+ // the `pricing-red` halt in src/run.js keys on cost alone and stays the ONLY pricing
421
+ // signal with teeth. This is a READOUT — how much of a run's spend was priced by a rate
422
+ // nobody vouched for — and coupling it to run control would be a new refusal, which is
423
+ // arbiter territory.
424
+
425
+ /**
426
+ * The two rate sources somebody VOUCHED for: `'provider'` (the provider reported its own
427
+ * authoritative cost, e.g. the claude CLI's `total_cost_usd`) and `'caller'` (we passed
428
+ * the rate in ourselves). Everything else bare-agent can report is a built-in guess.
429
+ *
430
+ * An ALLOW-LIST of vouched values, never a deny-list of guessed ones — this is the
431
+ * load-bearing line of the whole signal, for two reasons.
432
+ *
433
+ * First: BOTH of bareloop's production models resolve to `'tier'`, not to `'default'`
434
+ * (`claude-sonnet-5` and `claude-haiku-4-5-…` each match a recognized Claude tier by
435
+ * model-id substring, so bare-agent prices them off a hardcoded tier rate that could
436
+ * have drifted from Anthropic's). A "is this a guess?" test spelled
437
+ * `rateSource === 'default'` would therefore read EVERY normal bareloop round as
438
+ * confidently priced and see none of the population it exists to measure — this repo's
439
+ * blind-instrument class, seven recorded instances, and not becoming the eighth.
440
+ *
441
+ * Second: written this way, any value bare-agent adds later that we do not recognise
442
+ * reads as a GUESS rather than as vouched. The fail-safe direction, by construction.
443
+ */
444
+ export const VOUCHED_RATE_SOURCES = Object.freeze(['provider', 'caller']);
445
+
446
+ /**
447
+ * Read one spend record's rate PROVENANCE. Four-way, and the two absent-ish cases are
448
+ * deliberately distinct facts:
449
+ * - `'vouched'` — a `VOUCHED_RATE_SOURCES` value: somebody stands behind the rate.
450
+ * - `'guessed'` — a rate was used and nobody vouched for it (`'tier'`, `'default'`, or
451
+ * any future value we do not recognise).
452
+ * - `'unpriced'` — `rateSource: null`: nothing was priced, so there was no rate to have
453
+ * guessed. Upstream said so; we are not inferring it.
454
+ * - `'unknown'` — the record carries no `rateSource` at all. Every round archived before
455
+ * this signal existed lands here and is NEVER backfilled: an absent label
456
+ * is reported as unknown, never reconstructed, and never rounded up to
457
+ * vouched (F6's unknown-is-never-zero, applied to provenance).
458
+ * @param {any} record a `worker-round` / `judge-round` / `worker-turn` spine record
459
+ * @returns {'vouched'|'guessed'|'unpriced'|'unknown'}
460
+ */
461
+ export function rateProvenance(record) {
462
+ if (!record || typeof record !== 'object' || !('rateSource' in record)) return 'unknown';
463
+ const source = /** @type {any} */ (record).rateSource;
464
+ if (source === undefined) return 'unknown'; // a key carrying no value taught us nothing
465
+ if (source === null) return 'unpriced';
466
+ return VOUCHED_RATE_SOURCES.includes(source) ? 'vouched' : 'guessed';
467
+ }
468
+
469
+ /** The spend records a run's money is read from. `worker-round` and `judge-round` are the
470
+ * ACCOUNTED types — exactly `ACCOUNTED_ROUND_TYPES` in src/run.js, which is the ledger that
471
+ * enforces the budget (F12). `judge-round` is the softgreen rung's judged floor: the first
472
+ * spend a CLOSE has ever had, and a provenance readout that skipped it would be blind to the
473
+ * one seam whose rate nobody has audited. `worker-turn` is the native surface's per-turn
474
+ * attribution — unpriced by design, included so the readout covers every metering row the
475
+ * plan flow writes rather than only the ones carrying money. The attempt-level totals
476
+ * (`worker-result`, `worker-plan`) are ECHOES of these same rounds and are excluded, for
477
+ * the same reason the ledger in run.js excludes them: counting them double-counts.
478
+ *
479
+ * Re-spelled rather than imported (src/run.js imports this module's siblings and a cycle
480
+ * buys nothing here) — `tests/ledger.test.js` asserts the two spellings agree, because a
481
+ * spend-slicing instrument that misses a writer is the F45 class this list exists to avoid. */
482
+ const SPEND_RECORD_TYPES = Object.freeze(['worker-round', 'judge-round', 'worker-turn']);
483
+
484
+ /** @typedef {{rounds: number, usd: number, unpricedRounds: number}} ProvenanceBucket */
485
+
486
+ /**
487
+ * How much of a run's spend was priced by a guess. Pure fold over one spine's events,
488
+ * REPORTING ONLY — it mints nothing, halts nothing and refuses nothing.
489
+ *
490
+ * `usd` sums only FINITE costs; a round whose cost is not a finite number is counted in
491
+ * `unpricedRounds` instead of being summed as $0 (F6 — unpriced is never free), so a
492
+ * bucket's `usd` is a FLOOR whenever its `unpricedRounds` is non-zero.
493
+ * @param {any[]} events one spine's parsed events
494
+ * @returns {{vouched: ProvenanceBucket, guessed: ProvenanceBucket, unpriced: ProvenanceBucket, unknown: ProvenanceBucket}}
495
+ */
496
+ export function spendProvenance(events) {
497
+ /** @returns {ProvenanceBucket} */
498
+ const bucket = () => ({ rounds: 0, usd: 0, unpricedRounds: 0 });
499
+ const out = { vouched: bucket(), guessed: bucket(), unpriced: bucket(), unknown: bucket() };
500
+ for (const ev of events ?? []) {
501
+ if (!ev || !SPEND_RECORD_TYPES.includes(ev.type)) continue;
502
+ const b = out[rateProvenance(ev)];
503
+ b.rounds += 1;
504
+ if (typeof ev.costUsd === 'number' && Number.isFinite(ev.costUsd)) b.usd += ev.costUsd;
505
+ else b.unpricedRounds += 1;
506
+ }
507
+ return out;
508
+ }
package/src/plan.js CHANGED
@@ -27,6 +27,7 @@
27
27
  import { TOOL_MENU, LOCKED_TOOLS, WRITE_VERBS, checkMenu } from './job.js';
28
28
  import { declaredStages } from './declaredclose.js';
29
29
  import { globToPrefix, scopeContained, isObj, isNonEmptyString, sweepSecretLiterals, hasNestedQuantifier } from './validate.js';
30
+ import { READ_SHIM_CAP, readShimArm } from './readshim.js';
30
31
 
31
32
  /** the closed exit menu (PRD v1.12 §3 + decision 1's `check-passes`): the
32
33
  * shell evaluates every form with its own fixed code, never a command — the
@@ -49,6 +50,14 @@ export { WRITE_VERBS };
49
50
  * count. Shallow entries survive the cap (see `legalScopes`) because they are
50
51
  * the useful ones — a deep leaf is reachable via its parent. */
51
52
  export const MAX_SCOPE_MENU = 24;
53
+ /** G1's pair (read shim only): the two verbs that can reach INTO a file the cap
54
+ * cut short — `recall` names where a symbol lives, `get` reads that symbol in
55
+ * full. Named here because the rule that needs them lives here; the cap that
56
+ * makes them mandatory lives in src/readshim.js.
57
+ * EXPORTED because `runPlan` refuses the same pair at $0 (see the G1 ceiling
58
+ * guard there): the rule and its pre-flight must read ONE spelling of which
59
+ * verbs count, never two that can drift apart. */
60
+ export const RETRIEVAL_PAIR = Object.freeze(['recall', 'get']);
52
61
 
53
62
  /**
54
63
  * The MENU of legal `tree-changed` scopes — choose-don't-describe (design record
@@ -202,7 +211,7 @@ export function closeStagesOf(job) {
202
211
  * job spec. Never throws on JSON text or plain parsed data; every failure is
203
212
  * a named red. Returns the parsed plan on ok (single parse), null on any red.
204
213
  * @param {object|string} input parsed plan, or raw JSON text (parse failures are a red)
205
- * @param {{ job?: any, maxStepRounds?: number, scopes?: string[], seedRed?: string[], priorChecks?: string[] }} [opts] `job`: the
214
+ * @param {{ job?: any, maxStepRounds?: number, scopes?: string[], seedRed?: string[], priorChecks?: string[], readShim?: boolean|'cap'|'diff' }} [opts] `job`: the
206
215
  * validateJob-GREEN four-field spec (the ceiling, the fence, and the checks
207
216
  * menu all come from it — a missing or non-plan-shape job fails CLOSED);
208
217
  * `maxStepRounds`: the shell's per-step rounds ceiling (interpret's
@@ -224,9 +233,23 @@ export function closeStagesOf(job) {
224
233
  * only). Rule B — a redraft may not drop one (`check-shed`): exits without
225
234
  * the check verify FORM alone, so a shed lets the run "green" unearned
226
235
  * (u-msdsmkid mechanism b; the 3 archived sheds were all on step-red runs).
236
+ * `readShim` (G1): the read shim's ARM for this run — `false` (default), `'cap'`,
237
+ * `'diff'` or `true`. The arms that CAP (`'cap'`, `true`) add one rule: a step
238
+ * granting `read` must also grant `recall` and `get` (`read-blind`), because a
239
+ * capped read is only navigable with them. The arms that do not cap (`false`,
240
+ * `'diff'`) render byte-identically to the pre-shim validator, so the frozen A0
241
+ * baseline stays a baseline and A2 isolates the diff instead of importing an
242
+ * admission rule with it. An unrecognised value THROWS (`readShimArm`) — a param
243
+ * guard on the caller's own argument, never a plan red.
227
244
  * @returns {{ ok: boolean, reds: Red[], plan: object|null }}
228
245
  */
229
- export function validatePlan(input, { job, maxStepRounds = 40, scopes, seedRed, priorChecks } = {}) {
246
+ export function validatePlan(input, { job, maxStepRounds = 40, scopes, seedRed, priorChecks, readShim = false } = {}) {
247
+ // The one thing in here that throws, and it is not plan data: an unknown arm
248
+ // name is the CALLER's own argument, checked before a single plan field is
249
+ // read. The "never throws" contract above is about the agent's artifact —
250
+ // this is the operator's, and silently coercing it would run one arm under
251
+ // another's label (the BA-4 param-guard class; see `readShimArm`).
252
+ const shimArm = readShimArm(readShim);
230
253
  /** @type {Red[]} */
231
254
  const reds = [];
232
255
  /** @type {(code: string, path: string, detail?: string) => void} */
@@ -335,6 +358,24 @@ export function validatePlan(input, { job, maxStepRounds = 40, scopes, seedRed,
335
358
  }
336
359
  writeStep = s.tools.some((/** @type {string} */ t) => WRITE_VERBS.includes(t));
337
360
  toolsParsed = true;
361
+ // G1 — the read shim's admission rule, and it only exists while the CAP
362
+ // does. The shim caps a read at READ_SHIM_CAP and hands back a steer;
363
+ // capping a worker whose grant holds no retrieval verb leaves it with no
364
+ // way to reach the rest of the file, which is BA-17 read-blinding on
365
+ // purpose. So the cap is what makes the pair mandatory — the two ship
366
+ // together or neither ships.
367
+ // Fires only inside the parsed branch (the mailbox precedent: a rule
368
+ // derived from an unknowable grant charges the ledger for a violation
369
+ // the agent never committed), and only per step, because the grant IS
370
+ // the step boundary. Keyed on the ARM's g1 flag, not on "is the shim on":
371
+ // the diff-only arm (A2) caps nothing, so there is nothing to be blind
372
+ // about, and firing here would narrow A2's admissible plan space on top
373
+ // of the lever it is meant to isolate.
374
+ if (shimArm.g1 && s.tools.includes('read') && !RETRIEVAL_PAIR.every((/** @type {string} */ v) => s.tools.includes(v))) {
375
+ const short = RETRIEVAL_PAIR.filter((v) => !ceiling.includes(v));
376
+ red('read-blind', `${at}.tools`, `a step granting "read" must also grant ${RETRIEVAL_PAIR.join(' and ')} — reads are capped at ${READ_SHIM_CAP} bytes and the rest of a big file is reachable only through the retrieval verbs; a capped worker without them is blind to it`
377
+ + (short.length ? ` (the signed ceiling [${ceiling.join(', ')}] offers no ${short.join('/')}: this plan cannot satisfy the rule — the spec needs re-signing with them, or the run needs the shim off)` : ''));
378
+ }
338
379
  }
339
380
 
340
381
  // rounds ≤ the shell cap (cap-not-estimate; the step bound IS maxTurns)