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.
@@ -0,0 +1,246 @@
1
+ // THE REVIEW DOOR — the signer's three doors at the END of a run (softgreen
2
+ // module 8; PRD v1.71 §3, design record 2026-08-17 §3 + the 2026-08-18 addenda).
3
+ //
4
+ // THE LAW, hamr verbatim: *"it's important not to change the loop self verdict."*
5
+ // The close mints the verdict and the ledger records it before anything here runs.
6
+ // What a door records is a DISPOSITION — what the person wants done about a
7
+ // verdict, never what the verdict is. Nothing in this file returns, writes or
8
+ // implies an outcome, and the run it answers has already ended.
9
+ //
10
+ // WHY THIS IS NOT `runPlan`'s job. A door opens after the run is over: a person
11
+ // may answer minutes or days later, from a different process, and the answer
12
+ // cannot ride the run's own return path (module 6 named this when it left
13
+ // `applyDoorDecision` exported and unwired). So the door is OPENED by the run
14
+ // (one record on its spine, `src/planrun.js`) and ANSWERED here, off that record.
15
+ //
16
+ // THE THREE DOORS, and what each one costs:
17
+ // accept — the close's MECHANICAL stages re-run against the tree as it stands
18
+ // (hamr's N4 ruling: an accept is not a rubber stamp), and only then is
19
+ // the disposition recorded and — for a held judged green — the learning
20
+ // credit released. A red at re-run means the tree changed under the
21
+ // signer: the accept is REFUSED with the stage named. Never the judged
22
+ // or human stages: a machine may re-prove a machine's reading, and a
23
+ // judgment is not re-run behind the person who rendered it.
24
+ // rerun — a fresh engagement (§3.4). The person's words ARE the gap; empty
25
+ // words are refused at the same seam every other door is refused at.
26
+ // It re-proves nothing here — the NEW run's close is what judges.
27
+ // pause — not now. Nothing runs, nothing is spent, and the same three doors are
28
+ // open the next time somebody looks. An unanswered door expires under
29
+ // the existing 60-day checkpoint TTL, and that expiry IS what `cancel`
30
+ // used to do (doors addendum, 2026-08-18).
31
+ //
32
+ // The registry half is module 6's (`applyDoorDecision`), called from exactly one
33
+ // place here so a disposition cannot be recorded twice or by two spellings.
34
+
35
+ import { REVIEW_DOOR, HUMAN_DECISIONS, mechanicalStages, normalizeHumanRuling } from './kinds.js';
36
+ import { closeStagesOf } from './plan.js';
37
+ import { isDeclaredClose, runDeclaredStages, HITL_DECISION_RED } from './declaredclose.js';
38
+ import { runStages } from './ralph.js';
39
+ import { redactSecrets } from './validate.js';
40
+ import { applyDoorDecision, PAUSE_TTL_MS } from './reuse.js';
41
+
42
+ /** the door's own record, off the run's spine — the LAST one, because a run that
43
+ * ends at a door writes exactly one and a reader that guesses which is which is a
44
+ * reader that will one day answer the wrong question.
45
+ * @param {any[]} events the run's spine, parsed
46
+ * @returns {any|null} */
47
+ export function doorRecordOf(events) {
48
+ if (!Array.isArray(events)) return null;
49
+ return events.findLast?.((e) => e?.type === REVIEW_DOOR) ?? null;
50
+ }
51
+
52
+ /**
53
+ * IS THE DOOR STILL OPEN? The same 60-day TTL a hitl checkpoint keeps
54
+ * (`PAUSE_TTL_MS`) and deliberately the same NUMBER — the door replaced `cancel`
55
+ * precisely because an unanswered door and an unresumed pause are one state, and
56
+ * two TTLs for one state is how they come to disagree.
57
+ *
58
+ * An UNREADABLE stamp refuses too: unknown is not young (F6). Reading the age is
59
+ * not the same as deciding on it, so this states both the answer and the arithmetic.
60
+ * @param {any} door the door record
61
+ * @param {{now?: () => number, ttlMs?: number}} [opts]
62
+ * @returns {{ok: boolean, ageMs: number|null, ttlMs: number, detail: string}}
63
+ */
64
+ export function doorAgeGate(door, { now = Date.now, ttlMs = PAUSE_TTL_MS } = {}) {
65
+ const days = (/** @type {number} */ ms) => (ms / 86_400_000).toFixed(1);
66
+ const at = typeof door?.ts === 'string' ? Date.parse(door.ts) : NaN;
67
+ if (!Number.isFinite(at)) {
68
+ return { ok: false, ageMs: null, ttlMs, detail: 'the door carries no readable timestamp, and an unknown age is not a young one — the decision cannot be honoured on a record nobody can date' };
69
+ }
70
+ const ageMs = now() - at;
71
+ if (ageMs > ttlMs) {
72
+ return { ok: false, ageMs, ttlMs, detail: `the door was opened ${days(ageMs)} days ago and a door keeps for ${days(ttlMs)} days — it has expired on its own, which is exactly what nobody having to decide looks like` };
73
+ }
74
+ return { ok: true, ageMs, ttlMs, detail: `${days(ageMs)} days old, of ${days(ttlMs)}` };
75
+ }
76
+
77
+ /** one red, one spelling @param {string} code @param {string} detail @param {string} [path] */
78
+ const red = (code, detail, path = 'decision') => [{ code, path, detail }];
79
+
80
+ /**
81
+ * Answer a run's review door.
82
+ *
83
+ * Never throws and never conjures: a malformed decision, a run that opened no
84
+ * door, an expired one, a tree that moved and a registry that is not there all
85
+ * come back as named reds. The loop's verdict is untouched on every path — this
86
+ * function has no way to write one.
87
+ *
88
+ * @param {object} opts
89
+ * @param {any} opts.job the SIGNED spec the run was judged by (its close is what an accept re-proves)
90
+ * @param {string} opts.workdir the patient, as it stands NOW
91
+ * @param {any[]} opts.events the run's own spine, parsed
92
+ * @param {string} opts.decision one of the three doors
93
+ * @param {string|null} [opts.text] the rerun's words — the gap, and refused when empty
94
+ * @param {number} [opts.closeTimeoutMs] the per-stage bound for the accept's re-run
95
+ * @param {string|null} [opts.registryDir] the workflow registry, when the runner keeps one
96
+ * @param {string|null} [opts.name] the workflow this run wrote its row against
97
+ * @param {string|null} [opts.runid] the RUN's id, exactly as the bridge row records it
98
+ * @param {string|null} [opts.at] when the person answered
99
+ * @param {() => number} [opts.now] the clock, injected for the TTL
100
+ * @param {number} [opts.ttlMs]
101
+ * @param {((type: string, data: any) => any)|null} [opts.emit] the run's spine, to record the disposition on
102
+ * @returns {Promise<{ok: boolean, decision: string|null, next: string|null, released: boolean,
103
+ * doorRecorded: boolean, reds: any[], mechanical: any, door: any, text: string|null,
104
+ * options: string[], ttlMs: number, note: string|null}>}
105
+ */
106
+ export async function answerReviewDoor({
107
+ job, workdir, events, decision, text = null, closeTimeoutMs,
108
+ registryDir = null, name = null, runid = null, at = null,
109
+ now = Date.now, ttlMs = PAUSE_TTL_MS, emit = null,
110
+ }) {
111
+ /** @type {any} */
112
+ const out = {
113
+ ok: false, decision: null, next: null, released: false, doorRecorded: false,
114
+ reds: [], mechanical: null, door: null, text: null, options: [...HUMAN_DECISIONS], ttlMs, note: null,
115
+ };
116
+
117
+ // ── 1. THE OPERATOR'S OWN INPUT, at the LIBRARY's one seam. A fourth door and
118
+ // an empty rerun are refused by `normalizeHumanRuling` — the same rulebook the
119
+ // close's human stage is answered through, so a surface cannot admit what a run
120
+ // refuses.
121
+ const norm = normalizeHumanRuling({ decision, ...(text === null ? {} : { text }) });
122
+ if (!norm.ok) {
123
+ out.reds = red(HITL_DECISION_RED, /** @type {string} */ (norm.why));
124
+ return out;
125
+ }
126
+ const ruling = /** @type {{decision: string, text: string|null}} */ (norm.ruling);
127
+ out.decision = ruling.decision;
128
+ out.text = ruling.text;
129
+
130
+ // ── 2. IS THERE A DOOR? A run that opened none has nothing to answer, and
131
+ // conjuring one would let a decision be recorded against a verdict nobody was
132
+ // ever shown the evidence for.
133
+ const door = doorRecordOf(events);
134
+ if (door === null) {
135
+ out.reds = red('no-door', 'this run opened no review door — its verdict stands on its own, and there is nothing here to dispose of (a green-class run opens one only when the runner asks: --review-door)', 'events');
136
+ return out;
137
+ }
138
+ out.door = door;
139
+
140
+ // ── 3. IS IT STILL OPEN? The TTL is the cancel case: nobody has to decide, and
141
+ // a door nobody answered simply expires.
142
+ const age = doorAgeGate(door, { now, ttlMs });
143
+ if (!age.ok) {
144
+ out.reds = red('door-expired', age.detail, 'door.ts');
145
+ return out;
146
+ }
147
+
148
+ // ── 4. ACCEPT RE-PROVES THE TREE. Everything above is $0 and deterministic;
149
+ // this is the only branch that runs anything, and it runs the close's own
150
+ // mechanical stages — never a second, weaker spelling of "is it still green".
151
+ if (out.decision === 'accept') {
152
+ const proof = await proveMechanically({ job, workdir, events, closeTimeoutMs });
153
+ out.mechanical = proof.reading;
154
+ if (!proof.ok) {
155
+ out.reds = red('door-accept-red', proof.detail, proof.reading.stage ? `stage.${proof.reading.stage}` : 'close');
156
+ // NOTHING IS RECORDED on a refusal. A disposition the machine would not
157
+ // honour is not a disposition the record should carry: the person's answer
158
+ // was about a tree that no longer exists.
159
+ if (emit) emit('door-decision', { decision: 'accept', honoured: false, stage: proof.reading.stage ?? null, reason: 'the tree moved under the signer' });
160
+ return out;
161
+ }
162
+ }
163
+
164
+ // ── 5. THE DISPOSITION IS RECORDED, and only accept releases (module 6's
165
+ // `recordDoor`, reached through its one on-disk seam). Every door is a datum:
166
+ // *"the signer's accepts double as the judge's ongoing report card"* (v1.71 §3),
167
+ // and a rerun over a judged green is exactly as informative as an accept.
168
+ if (registryDir && name && runid) {
169
+ // ALREADY-GREEN MINTS NOTHING, from the other side: a run that changed
170
+ // nothing wrote no row of its own, so there is nothing to record a door on and
171
+ // nothing to release. Stated rather than surfaced as a `no-row-for-run` red —
172
+ // the rule is the design, not a failure.
173
+ if (door.outcome === 'already-green') {
174
+ out.note = 'already-green: the run minted no row of its own, so the door records nothing and releases nothing — accept confirms a verdict, it never mints one';
175
+ } else {
176
+ const r = applyDoorDecision({ registryDir, name, runid, decision: out.decision, at });
177
+ if (!r.ok) {
178
+ out.reds = r.reds;
179
+ return out;
180
+ }
181
+ out.released = r.released;
182
+ out.doorRecorded = true;
183
+ }
184
+ }
185
+
186
+ out.ok = true;
187
+ out.next = out.decision === 'accept' ? 'accepted' : (out.decision === 'rerun' ? 'rerun' : 'paused');
188
+ if (emit) {
189
+ emit('door-decision', {
190
+ decision: out.decision,
191
+ honoured: true,
192
+ ...(out.text === null ? {} : { text: redactSecrets(out.text) }),
193
+ released: out.released,
194
+ recorded: out.doorRecorded,
195
+ meaning: 'a disposition — the verdict this run minted is untouched, and nothing here can change it',
196
+ });
197
+ }
198
+ return out;
199
+ }
200
+
201
+ /**
202
+ * The accept's re-proof: the close's MECHANICAL stages, run against the tree as it
203
+ * stands now, through the SAME executors the run itself was judged by.
204
+ *
205
+ * The declared path measures against the run's OWN seed (`close-decl.seedRef` off
206
+ * the spine), never a fresh one: re-basing the baseline onto today's HEAD would
207
+ * compare the tree to itself and turn "nothing has changed since the run" into a
208
+ * green for a `files-changed` stage that means the opposite. A run whose seed is
209
+ * not on its spine is refused rather than guessed at.
210
+ * @param {{job: any, workdir: string, events: any[], closeTimeoutMs?: number}} o
211
+ */
212
+ async function proveMechanically({ job, workdir, events, closeTimeoutMs }) {
213
+ const stages = mechanicalStages(closeStagesOf(job) ?? []);
214
+ if (!stages.length) {
215
+ // Honest, and named: a close with no mechanical stage has nothing a machine
216
+ // can re-prove, so the accept rests on the person alone. Never a silent pass
217
+ // dressed as a re-run.
218
+ return { ok: true, detail: '', reading: { ran: 0, verdict: null, stage: null, stages: [], note: 'this close has no mechanical stage — there is nothing a machine can re-prove, so the accept rests on the person' } };
219
+ }
220
+ /** @type {any} */
221
+ let v;
222
+ if (isDeclaredClose(job)) {
223
+ const seedRef = events.findLast?.((e) => e?.type === 'close-decl' && typeof e.seedRef === 'string')?.seedRef ?? null;
224
+ if (!seedRef) {
225
+ return { ok: false, detail: 'this run left no seed on its spine (`close-decl.seedRef`), and a declared close measures against a seed — re-proving against a guessed baseline would grade a tree nobody chose', reading: { ran: 0, verdict: null, stage: null, stages: [] } };
226
+ }
227
+ v = await runDeclaredStages(stages, redactSecrets, { timeoutMs: closeTimeoutMs, cwd: workdir, seedRef });
228
+ } else {
229
+ v = await runStages(stages, redactSecrets, { timeoutMs: closeTimeoutMs, cwd: workdir });
230
+ }
231
+ const reading = {
232
+ ran: stages.length,
233
+ verdict: v.verdict ?? null,
234
+ stage: v.stage ?? null,
235
+ stages: v.stages ?? [],
236
+ ...(v.gap ? { gap: v.gap } : {}),
237
+ };
238
+ if (v.verdict === 'satisfied') return { ok: true, detail: '', reading };
239
+ return {
240
+ ok: false,
241
+ reading,
242
+ detail: `the close's mechanical stage "${v.stage ?? 'unknown'}" no longer passes on this tree (${v.verdict}) — `
243
+ + 'the tree moved under you, so the accept is refused rather than applied to work you did not read. '
244
+ + `${redactSecrets(String(v.gap ?? v.detail ?? '')).split('\n').slice(0, 3).join(' ')}`.trim(),
245
+ };
246
+ }
package/src/run.js CHANGED
@@ -15,10 +15,27 @@ import { join } from 'node:path';
15
15
  import { LiteCtx } from 'litectx';
16
16
  import { validateJob, jobSpecHash, checkApproval } from './job.js';
17
17
  import { runPlan } from './planrun.js';
18
- import { SMOKE_STORE } from './kinds.js';
18
+ import { SMOKE_STORE, resolveHumanRuling } from './kinds.js';
19
+ import { readShimArm } from './readshim.js';
19
20
 
20
21
  /** @typedef {{code: string, path: string, detail?: string}} Red */
21
22
 
23
+ /**
24
+ * THE ROUND RECORDS THE ONE LEDGER SUMS — as data, because two of them now exist
25
+ * and a hand-spelled condition per record is how a paid call comes to be free.
26
+ *
27
+ * `worker-round` is the attempt's own spend, metered per ROUND rather than per
28
+ * attempt-return (F12: a halted attempt's spend was invisible by 300×).
29
+ * `judge-round` is the CLOSE's, and it is the first spend a close has ever had:
30
+ * the judged floor (softgreen) buys a pinned locate call per artifact, and the
31
+ * budget-identity rule is that a budget funds the attempt PLUS its close. Both
32
+ * carry the honest `costUsd` — a null lands as `unpriced` and halts, never as $0.
33
+ *
34
+ * What is deliberately NOT here: `worker-result` / `worker-plan`, which are
35
+ * attempt TOTALS of the same rounds and would double-count.
36
+ */
37
+ export const ACCOUNTED_ROUND_TYPES = Object.freeze(['worker-round', 'judge-round']);
38
+
22
39
 
23
40
  /**
24
41
  * Known-answer smoke on the primitive the run is about to trust (PRD v1.5 §4,
@@ -77,6 +94,13 @@ async function primitiveSmoke(workdir) {
77
94
  * metered claude-json text provider for the toolless drafter); ignored on every Loop-driven provider
78
95
  * @param {(type: string, data?: object) => object} opts.emit spine emitter
79
96
  * @param {(tier: string) => any} [opts.providerFor] P: per-step model-tier provider factory (forwarded to the plan flow)
97
+ * @param {any} [opts.judgeProvider] SOFTGREEN — the provider a JUDGED close stage runs its
98
+ * locate call through, wired by the operator and PINNED to `JUDGE_MODEL` (src/judged.js:
99
+ * the only tier with established injection resistance upstream). Forwarded to the plan
100
+ * flow. It is its own seam and not `provider`/`providerFor`, because the judge tier is
101
+ * never a step knob and never agent-selectable; absent, a judged stage instrument-stops as
102
+ * a wiring gap rather than grading on whatever binding happened to be at hand. Its spend
103
+ * lands on this ledger as `judge-round` (see `ACCOUNTED_ROUND_TYPES`).
80
104
  * @param {number} [opts.capRuns] shell-owned iteration cap for the plan flow's
81
105
  * CLOSE-FIX loop (a plan STEP runs under the strike ladder instead)
82
106
  * @param {number} [opts.strikeLimit] shell-owned strike ceiling for a plan step's ladder
@@ -133,6 +157,19 @@ async function primitiveSmoke(workdir) {
133
157
  * collision walk would mint a `-2` beside the work the resume exists to continue. The
134
158
  * fold's precedent exactly: the chain's state is DECLARED, never re-derived. Forwarded
135
159
  * verbatim to the plan flow; absent is the cold path.
160
+ * @param {boolean|'cap'|'diff'} [opts.readShim=false] THE READ SHIM (src/readshim.js) —
161
+ * WHICH ARM to run, threaded to the plan flow. The Phase 2 pre-registration's four:
162
+ * `false` A0 — off (the default)
163
+ * `'cap'` A1 — cap + pointer + next-unseen-slice + G1, no diff
164
+ * `'diff'` A2 — the diff lever alone: no cap, no pointer, no G1
165
+ * `true` A3 — every lever
166
+ * Anything else THROWS below, before a token is spent: a mis-spelled arm coerced
167
+ * by truthiness into A3 would run one treatment under another's label and be
168
+ * invisible in the results afterwards.
169
+ * OFF is byte-identical to the pre-shim run in every observable (G1 included): the
170
+ * frozen A0 baseline arm has to be exactly today, so a guard firing under a disabled
171
+ * shim would make the baseline a treatment arm. The default-flip belongs to a
172
+ * paid contrast nobody has approved yet (the `layerRoot` precedent, F41).
136
173
  * @param {boolean} [opts.layerRoot=false] Layer R (within-run ratchet) — shell
137
174
  * territory, threaded to the plan flow. Defaults OFF
138
175
  * (decided 2026-07-21): fixation is extinct on every current job (F41), so
@@ -145,13 +182,34 @@ async function primitiveSmoke(workdir) {
145
182
  * RESUMES a paused run and forwarded verbatim to the plan flow. Absent on every
146
183
  * ordinary run; never authored and never defaulted (a defaulted answer to "is this
147
184
  * done?" is the rubber-stamp the class exists to prevent, 2026-08-12 §4).
185
+ * @param {{decision: string, text?: string|null, receivedAt?: string|null}|null} [opts.heldRuling]
186
+ * F102 — the answer the CHECKPOINT holds (`readResume`'s `restart.pendingDecision`):
187
+ * a decision a person already gave on a leg that stopped before a single round was
188
+ * bought for it. Forwarded verbatim; the plan flow applies it exactly as a fresh one
189
+ * and refuses a leg handed both. It is also what decides whether the TIME fold this
190
+ * run declares is the chain's or this engagement's (F103).
191
+ * @param {boolean|null} [opts.reviewDoor] MODULE 8 — does this run END at the review
192
+ * door (PRD v1.71 §3)? Forwarded verbatim to the plan flow, which owns the class
193
+ * default. It changes what the run RECORDS and never what it returns: the door is a
194
+ * disposition and the loop's verdict is not the door's to touch.
195
+ * @param {{text: string, fromRunid?: string|null, receivedAt?: string|null}|null} [opts.doorRerun]
196
+ * MODULE 8 — this leg IS the fresh engagement a signer commissioned at a previous
197
+ * run's review door, carrying their words. Forwarded verbatim.
148
198
  * @returns {Promise<string>} outcome: 'green' | 'already-green' | 'escalated' |
149
199
  * 'unapproved-spec' | 'job-red' | 'smoke-red' | 'plan-red' | 'check-red' |
150
200
  * 'close-red' | 'close-unsupported' | 'recipe-stale' | 'branch-red' | 'pricing-red' | 'provider-red' |
151
201
  * 'interpreter-red' | 'cap-halt' | 'wall-halt' | 'step-stalled' |
152
202
  * 'hitl-pause' | 'hitl-decision-red' | `step-red:<id>`
153
203
  */
154
- export async function runJob(rawSpec, { approvals, workdir, provider, nativeProvider, providerFor, emit, capRuns = 3, strikeLimit, shellCapUsd = 2, closeTimeoutMs, layerRoot = false, bridge = null, priorSpentUsd = 0, priorSpendComplete = true, priorWallMs = 0, resumeSeed = null, resumeGrades = [], resumeReplans = null, resumeBranch = null, humanRuling = null }) {
204
+ export async function runJob(rawSpec, { approvals, workdir, provider, nativeProvider, providerFor, judgeProvider = null, emit, capRuns = 3, strikeLimit, shellCapUsd = 2, closeTimeoutMs, layerRoot = false, readShim = false, bridge = null, priorSpentUsd = 0, priorSpendComplete = true, priorWallMs = 0, resumeSeed = null, resumeGrades = [], resumeReplans = null, resumeBranch = null, humanRuling = null, heldRuling = null, reviewDoor = null, doorRerun = null }) {
205
+ // THE READ SHIM's ARM, resolved at the door — the FIRST thing this entry does,
206
+ // before the ledger, before the approval gate, before a byte of the spec is read.
207
+ // An unrecognised spelling throws here at zero cost instead of being coerced by
208
+ // truthiness into A3 and running a whole paid battery row under an arm label it
209
+ // never executed — a wrong result that looks like a fine one, which is the exact
210
+ // blind-instrument failure this programme keeps logging. Only the guard runs
211
+ // here; the flag itself is threaded onward to the plan flow as written.
212
+ readShimArm(readShim);
155
213
  // 0. the ledger's counters, declared FIRST so that every job-end — including
156
214
  // the pre-token reds below — can state a real figure. An omitted `spentUsd` is
157
215
  // not a zero: a consumer reads `undefined` and either crashes or launders it
@@ -162,6 +220,11 @@ export async function runJob(rawSpec, { approvals, workdir, provider, nativeProv
162
220
  // number that enters the arbiter's arithmetic: a garbage fold is 0, never a NaN
163
221
  // that would poison every later comparison into "no cap".
164
222
  let spentUsd = typeof priorSpentUsd === 'number' && Number.isFinite(priorSpentUsd) && priorSpentUsd > 0 ? priorSpentUsd : 0;
223
+ /** F103's money half — the CHAIN's fold, kept as its own figure so `spentUsd`
224
+ * (the chain total, what a person reads and what the ceiling governs) and THIS
225
+ * ENGAGEMENT's spend can be stated side by side on every terminal. Two things
226
+ * counted by one number will eventually be asked to disagree. */
227
+ const chainFoldUsd = spentUsd;
165
228
  let unpriced = false;
166
229
  // RESUME (v1.46 §3): was the FOLD itself exact? `readResume` marks it false when any
167
230
  // round inside the dead attempt came back unpriced, and that unknown does not heal by
@@ -201,7 +264,16 @@ export async function runJob(rawSpec, { approvals, workdir, provider, nativeProv
201
264
  // ONE spelling of the four causes, so the terminal and the in-flight money readout can
202
265
  // never disagree about whether the same run's figure is exact.
203
266
  const spendComplete = () => !unpriced && !stalled && !cutMidCall && !priorFloor;
204
- const spend = () => ({ spentUsd, spendComplete: spendComplete() });
267
+ const spend = () => ({
268
+ spentUsd,
269
+ spendComplete: spendComplete(),
270
+ // F103's second counter, on every terminal beside the chain total it is NOT.
271
+ // `spentUsd` is cumulative-so-far (this job, across engagements) and governs the
272
+ // ceiling; this is what THIS engagement spent. Always emitted, never conditional
273
+ // on there being a fold: a consumer that has to branch on field presence is a
274
+ // consumer that will read absence as zero.
275
+ engagementSpentUsd: Math.max(0, spentUsd - chainFoldUsd),
276
+ });
205
277
  // 1. human-signs-always — before ANY provider call (N1 decision #1)
206
278
  if (!checkApproval(rawSpec, approvals)) {
207
279
  emit('job-end', { outcome: 'unapproved-spec', detail: 'no approval record matches this exact spec version', ...spend() });
@@ -214,6 +286,11 @@ export async function runJob(rawSpec, { approvals, workdir, provider, nativeProv
214
286
  return 'job-red';
215
287
  }
216
288
  const job = /** @type {any} */ (jv.job);
289
+ /** F103 — the two TIME figures, resolved once here and read the same way by
290
+ * `runPlan`'s clock. An unreadable ruling resolves to `fresh: false` and folds as
291
+ * it always did; `runPlan` is where it is refused, so this never has to. */
292
+ const chainWallMs = typeof priorWallMs === 'number' && Number.isFinite(priorWallMs) && priorWallMs > 0 ? priorWallMs : 0;
293
+ const wallFold = resolveHumanRuling(humanRuling, heldRuling, doorRerun).fresh ? 0 : chainWallMs;
217
294
  emit('job-start', {
218
295
  job: job.job, specHash: jobSpecHash(job), budgetUsd: job.budgetUsd,
219
296
  shape: 'plan', goal: job.goal,
@@ -226,7 +303,17 @@ export async function runJob(rawSpec, { approvals, workdir, provider, nativeProv
226
303
  // A fold of $0 that is NOT exact is still a fold — the unknown is the whole of what
227
304
  // it inherited, and it is the only field that can carry it forward.
228
305
  ...(spentUsd > 0 || priorFloor ? { priorSpentUsd: spentUsd, priorSpendComplete: !priorFloor } : {}),
229
- ...(typeof priorWallMs === 'number' && Number.isFinite(priorWallMs) && priorWallMs > 0 ? { priorWallMs } : {}),
306
+ // …and the TIME fold, which F103 splits in two. `priorWallMs` is what the next
307
+ // reader ADDS to (the bound), and it is engagement-scoped: a rerun opens a fresh
308
+ // engagement, so the minutes the person did not commission are not a bound on
309
+ // anybody. `chainWallMs` is the cumulative view and is declared whenever there is
310
+ // one, so nothing is lost — the readout keeps spanning the chain (the halt readout
311
+ // and the leg's own governor answer different questions and are never mixed).
312
+ // The `fresh` reading comes from the ONE resolver `runPlan`'s clock reads, never a
313
+ // second spelling of "is this a rerun" (that is how a record and a clock come to
314
+ // disagree about the same leg).
315
+ ...(wallFold > 0 ? { priorWallMs: wallFold } : {}),
316
+ ...(chainWallMs > 0 && chainWallMs !== wallFold ? { chainWallMs } : {}),
230
317
  // the REPLAN ledger, on the same declaration and for the same reason — a chain of
231
318
  // resumes must fold once. This is the DIRECT-spine half of it: `readResume`'s
232
319
  // `direct` mode opens its one implicit window on `job-start` and reads the fold off
@@ -265,7 +352,9 @@ export async function runJob(rawSpec, { approvals, workdir, provider, nativeProv
265
352
  // `account` reds the run on ANY unpriced round (a null cost is the honest
266
353
  // unknown, never $0 — F6): per-round metering means a partially-unpriced run
267
354
  // is caught natively, round by round, with no separate unpricedRounds tally.
268
- if (type === 'worker-round') account(/** @type {any} */ (data)?.costUsd);
355
+ // …and the CLOSE's own rounds sum into the same wallet the moment a close can
356
+ // spend at all (the judged floor): one ledger, never a second arithmetic.
357
+ if (ACCOUNTED_ROUND_TYPES.includes(type)) account(/** @type {any} */ (data)?.costUsd);
269
358
  // W1: a `stall` is the plan flow announcing it ABANDONED a call and reissued
270
359
  // it. The abandoned call's spend is unknowable from here (billed or not,
271
360
  // indistinguishable), so from this point the priced sum is a floor no matter
@@ -289,7 +378,7 @@ export async function runJob(rawSpec, { approvals, workdir, provider, nativeProv
289
378
  // accounts it natively (F12) and the job-end money contract is unchanged.
290
379
  {
291
380
  const outcome = await runPlan(job, {
292
- workdir, provider, nativeProvider, providerFor, emit: meter, capRuns, ...(strikeLimit !== undefined ? { strikeLimit } : {}), closeTimeoutMs, layerRoot, bridge, priorWallMs, resumeSeed, resumeGrades, resumeReplans, resumeBranch, humanRuling,
381
+ workdir, provider, nativeProvider, providerFor, judgeProvider, emit: meter, capRuns, ...(strikeLimit !== undefined ? { strikeLimit } : {}), closeTimeoutMs, layerRoot, readShim, bridge, priorWallMs: chainWallMs, resumeSeed, resumeGrades, resumeReplans, resumeBranch, humanRuling, heldRuling, reviewDoor, doorRerun, priorSpentUsd: chainFoldUsd,
293
382
  remainingUsd: () => Math.min(shellCapUsd, job.budgetUsd - spentUsd),
294
383
  isUnpriced: () => unpriced, // F6: let the plan flow bail in-flight, not just after it returns
295
384
  spendComplete, // …and let its money-halt readout say whether the remaining it quotes is exact
package/src/selection.js CHANGED
@@ -26,7 +26,7 @@
26
26
  // in either direction, a percentage over 2–3 runs is fake precision, and if a score ever
27
27
  // earns its way in its threshold is hamr's, from a measured base rate.
28
28
 
29
- import { listingRow } from './bridges.js';
29
+ import { listingRow, QUARANTINED_CODE } from './bridges.js';
30
30
 
31
31
  /** minutes, one decimal — the granularity a human picks a workflow at (the unit is
32
32
  * written once, by the band) */
@@ -74,10 +74,22 @@ export function renderListing(registry) {
74
74
  const reds = Array.isArray(r?.reds) ? r.reds : [];
75
75
  const rows = bridges.map(listingRow).sort((a, b) => String(a.name).localeCompare(String(b.name)));
76
76
 
77
- const skipped = reds.length
78
- ? [`\n\nNOTE: ${reds.length} registry entr${reds.length === 1 ? 'y' : 'ies'} could not be read and ${reds.length === 1 ? 'is' : 'are'} NOT listed — this listing is smaller than the directory:`,
79
- ...reds.map((/** @type {any} */ x) => ` ${x.path ?? '(unknown file)'}${x.detail ? ` — ${x.detail}` : ''}`)].join('\n')
80
- : '';
77
+ // TWO KINDS OF SKIP, and they are never written as one. An unreadable entry is a
78
+ // broken file; a HELD one (softgreen module 6) is a perfectly good workflow whose
79
+ // judged green no signer has accepted yet. Saying "could not be read" about the
80
+ // second would send the operator to fix a file that is not broken.
81
+ const broken = reds.filter((/** @type {any} */ x) => x?.code !== QUARANTINED_CODE);
82
+ const held = reds.filter((/** @type {any} */ x) => x?.code === QUARANTINED_CODE);
83
+ const skipped = [
84
+ broken.length
85
+ ? [`\n\nNOTE: ${broken.length} registry entr${broken.length === 1 ? 'y' : 'ies'} could not be read and ${broken.length === 1 ? 'is' : 'are'} NOT listed — this listing is smaller than the directory:`,
86
+ ...broken.map((/** @type {any} */ x) => ` ${x.path ?? '(unknown file)'}${x.detail ? ` — ${x.detail}` : ''}`)].join('\n')
87
+ : '',
88
+ held.length
89
+ ? [`\n\nNOTE: ${held.length} workflow${held.length === 1 ? ' is' : 's are'} HELD and NOT offered — a judged green earns no reuse until the person who owns the job accepts it:`,
90
+ ...held.map((/** @type {any} */ x) => ` ${x.path ?? '(unnamed)'}${x.detail ? ` — ${x.detail}` : ''}`)].join('\n')
91
+ : '',
92
+ ].join('');
81
93
 
82
94
  if (!rows.length) {
83
95
  return `WORKFLOWS ON RECORD — none.\nThere are no workflows to reuse; a plan has to be drafted from scratch.${skipped}`;
@@ -86,7 +98,11 @@ export function renderListing(registry) {
86
98
  const blocks = rows.map((row) => {
87
99
  const g = row.greenCost;
88
100
  return [
89
- `[${row.name ?? '(unnamed)'}] ${(row.status ?? 'no-green').toUpperCase()}`,
101
+ // an entry whose only greens are HELD has no status — but "NO-GREEN" would be
102
+ // false about it: it greened, and a person has simply not accepted the judged
103
+ // verdict yet. The distinct word is the whole difference between "this never
104
+ // worked" and "this is waiting on you".
105
+ `[${row.name ?? '(unnamed)'}] ${(row.status ?? (row.quarantinedGreens > 0 ? 'held' : 'no-green')).toUpperCase()}`,
90
106
  ` goal ${row.goal ?? '(no goal recorded)'}`,
91
107
  ` runs ${row.greens} green · ${row.reds} red${row.lastOutcome ? ` (last: ${row.lastOutcome})` : ''}`,
92
108
  ` green cost ${band(g.minUsd, g.maxUsd, usd, g.unpricedCount, row.greens, 'unpriced')}`
package/src/tools.js CHANGED
@@ -21,7 +21,7 @@ const require = createRequire(import.meta.url);
21
21
  const { createShellTools } = require('bare-agent/tools');
22
22
  // litectx's rank-tiered render — a free function, not a ctx method (its contract's
23
23
  // own recipe: the caller slices the chunk body, compress renders the signature tier)
24
- const { compress: compressNode } = require('litectx');
24
+ const { compress: compressNode, StalePointerError } = require('litectx');
25
25
 
26
26
  import { extractArtifact, priceOf } from './text.js';
27
27
  // The isolate verbs write MODEL-AUTHORED text into `<workdir>/.litectx` — a file
@@ -243,8 +243,13 @@ const LINE_SPACE = 'Line numbers here are 0-BASED index positions from the repos
243
243
  * RESULT is invisible cannot be judged: a `ctx_get` that silently reds (stale pointer,
244
244
  * bad range) looks exactly like one that worked, and the worker's fallback to a
245
245
  * whole-file read looks like a free choice instead of a forced one.
246
+ * @param {{onStalePointer?: ((req: {file: string, path: string, startLine: number, endLine: number, detail: string}) => {text: string, outcome: string}|null)|null}} [opts]
247
+ * `onStalePointer`: the read shim's stale-pointer serve, wired ONLY by a capping
248
+ * arm (see `createReadShim`). Absent — every caller today except that one — this
249
+ * whole seam is byte-identical to what it was, which is what keeps A0 the
250
+ * untouched baseline an experiment has already run against.
246
251
  */
247
- export function createCtxTools(lc, workdir, emit) {
252
+ export function createCtxTools(lc, workdir, emit, { onStalePointer = null } = {}) {
248
253
  // ctx_get's failure contract, generalised to the palette (review 2026-07-31).
249
254
  // `emit` exists in this file because a verb whose RESULT is invisible cannot be
250
255
  // judged — and a THROWN call is the most invisible result there is: it looks
@@ -337,6 +342,33 @@ export function createCtxTools(lc, workdir, emit) {
337
342
  // StalePointerError: the file changed after indexing, so these lines now describe
338
343
  // DIFFERENT code. Its message IS the recovery instruction — hand it to the worker.
339
344
  const detail = String(/** @type {Error} */ (e)?.message || e);
345
+ // …and under a CAPPING read-shim arm, the recovery instruction is not
346
+ // enough. That arm's own strategy line steers the worker HERE for a whole
347
+ // function, and a worker that just edited the file it is working on — the
348
+ // normal case — gets a stale pointer and zero bytes for a paid round
349
+ // (measured: 5 of 10 ctx_get calls across the A1 arm's three green runs).
350
+ // So the shim serves the requested LINE RANGE from disk, labelled; see
351
+ // `serveStale`. The branch is on the TYPED error, never on the message
352
+ // prose — a prose sniff would fire on any failure whose wording happens
353
+ // to match, and litectx exports the class precisely so it need not.
354
+ if (onStalePointer && e instanceof StalePointerError) {
355
+ // The gate judged `resolve(workdir, expandHome(path))` for this call
356
+ // (toolAction, 'ctx_get'); the serve reads that same absolute path and
357
+ // no other, so it can never reach a byte the fence did not allow.
358
+ const served = onStalePointer({
359
+ file: resolve(workdir, expandHome(String(p))),
360
+ path: rel,
361
+ startLine: Number(startLine),
362
+ endLine: Number(endLine),
363
+ detail,
364
+ });
365
+ if (served && typeof served.text === 'string') {
366
+ // No `detail` on the event: the served TEXT is file content and the
367
+ // spine is append-only. Bytes and the outcome are what a reader needs.
368
+ emit('ctx-tool', { tool: 'ctx_get', path: rel, startLine, endLine, outcome: served.outcome, bytes: Buffer.byteLength(served.text) });
369
+ return served.text;
370
+ }
371
+ }
340
372
  emit('ctx-tool', { tool: 'ctx_get', path: rel, startLine, endLine, outcome: 'stale', bytes: 0, detail });
341
373
  return `stale pointer: ${detail}`;
342
374
  }