bareloop 0.10.0 → 0.12.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/root.js CHANGED
@@ -1,5 +1,5 @@
1
1
  // Layer R — the root: the within-run ratchet (LAYERS.md item 2; design record
2
- // docs/plans/2026-07-19-layer-r-design.md). One state that survives attempts
2
+ // docs/02-features/2026-07-19-layer-r-design.md). One state that survives attempts
3
3
  // inside ONE run so a never-green run stops repeating itself (F21). Role is
4
4
  // CONTINUITY ONLY — F39 measured that hand-delivered state buys no conversion;
5
5
  // the semantic converter is Layer 2's job, not this module's.
package/src/run.js CHANGED
@@ -3,7 +3,7 @@
3
3
  // smoke, the ONE budget ledger (cap-not-estimate; unpriced is never free, F6),
4
4
  // and the decision-ready stops. It composes the plan flow below it and
5
5
  // interprets nothing itself.
6
- // Design record: docs/plans/2026-07-12-n2-headless-loop-design.md.
6
+ // Design record: docs/02-features/2026-07-12-n2-headless-loop-design.md.
7
7
  //
8
8
  // The legacy operator-authored `steps[]` path was DELETED 2026-07-26 (PRD
9
9
  // v1.32): the drafting call, config-v1, the per-step interpret loops and the
@@ -15,10 +15,26 @@ 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
19
 
20
20
  /** @typedef {{code: string, path: string, detail?: string}} Red */
21
21
 
22
+ /**
23
+ * THE ROUND RECORDS THE ONE LEDGER SUMS — as data, because two of them now exist
24
+ * and a hand-spelled condition per record is how a paid call comes to be free.
25
+ *
26
+ * `worker-round` is the attempt's own spend, metered per ROUND rather than per
27
+ * attempt-return (F12: a halted attempt's spend was invisible by 300×).
28
+ * `judge-round` is the CLOSE's, and it is the first spend a close has ever had:
29
+ * the judged floor (softgreen) buys a pinned locate call per artifact, and the
30
+ * budget-identity rule is that a budget funds the attempt PLUS its close. Both
31
+ * carry the honest `costUsd` — a null lands as `unpriced` and halts, never as $0.
32
+ *
33
+ * What is deliberately NOT here: `worker-result` / `worker-plan`, which are
34
+ * attempt TOTALS of the same rounds and would double-count.
35
+ */
36
+ export const ACCOUNTED_ROUND_TYPES = Object.freeze(['worker-round', 'judge-round']);
37
+
22
38
 
23
39
  /**
24
40
  * Known-answer smoke on the primitive the run is about to trust (PRD v1.5 §4,
@@ -77,6 +93,13 @@ async function primitiveSmoke(workdir) {
77
93
  * metered claude-json text provider for the toolless drafter); ignored on every Loop-driven provider
78
94
  * @param {(type: string, data?: object) => object} opts.emit spine emitter
79
95
  * @param {(tier: string) => any} [opts.providerFor] P: per-step model-tier provider factory (forwarded to the plan flow)
96
+ * @param {any} [opts.judgeProvider] SOFTGREEN — the provider a JUDGED close stage runs its
97
+ * locate call through, wired by the operator and PINNED to `JUDGE_MODEL` (src/judged.js:
98
+ * the only tier with established injection resistance upstream). Forwarded to the plan
99
+ * flow. It is its own seam and not `provider`/`providerFor`, because the judge tier is
100
+ * never a step knob and never agent-selectable; absent, a judged stage instrument-stops as
101
+ * a wiring gap rather than grading on whatever binding happened to be at hand. Its spend
102
+ * lands on this ledger as `judge-round` (see `ACCOUNTED_ROUND_TYPES`).
80
103
  * @param {number} [opts.capRuns] shell-owned iteration cap for the plan flow's
81
104
  * CLOSE-FIX loop (a plan STEP runs under the strike ladder instead)
82
105
  * @param {number} [opts.strikeLimit] shell-owned strike ceiling for a plan step's ladder
@@ -140,12 +163,31 @@ async function primitiveSmoke(workdir) {
140
163
  * a manufactured-fixation probe). `true` is the ON/experimental arm; the
141
164
  * default is the OFF arm. (Shell-owned seam, same doctrine as the provider
142
165
  * binding.)
166
+ * @param {{decision: string, text?: string}|null} [opts.humanRuling] N4 — the SIGNER's
167
+ * answer at a hitl pause (accept | rerun <text> | pause), carried by the leg that
168
+ * RESUMES a paused run and forwarded verbatim to the plan flow. Absent on every
169
+ * ordinary run; never authored and never defaulted (a defaulted answer to "is this
170
+ * done?" is the rubber-stamp the class exists to prevent, 2026-08-12 §4).
171
+ * @param {{decision: string, text?: string|null, receivedAt?: string|null}|null} [opts.heldRuling]
172
+ * F102 — the answer the CHECKPOINT holds (`readResume`'s `restart.pendingDecision`):
173
+ * a decision a person already gave on a leg that stopped before a single round was
174
+ * bought for it. Forwarded verbatim; the plan flow applies it exactly as a fresh one
175
+ * and refuses a leg handed both. It is also what decides whether the TIME fold this
176
+ * run declares is the chain's or this engagement's (F103).
177
+ * @param {boolean|null} [opts.reviewDoor] MODULE 8 — does this run END at the review
178
+ * door (PRD v1.71 §3)? Forwarded verbatim to the plan flow, which owns the class
179
+ * default. It changes what the run RECORDS and never what it returns: the door is a
180
+ * disposition and the loop's verdict is not the door's to touch.
181
+ * @param {{text: string, fromRunid?: string|null, receivedAt?: string|null}|null} [opts.doorRerun]
182
+ * MODULE 8 — this leg IS the fresh engagement a signer commissioned at a previous
183
+ * run's review door, carrying their words. Forwarded verbatim.
143
184
  * @returns {Promise<string>} outcome: 'green' | 'already-green' | 'escalated' |
144
185
  * 'unapproved-spec' | 'job-red' | 'smoke-red' | 'plan-red' | 'check-red' |
145
186
  * 'close-red' | 'close-unsupported' | 'recipe-stale' | 'branch-red' | 'pricing-red' | 'provider-red' |
146
- * 'interpreter-red' | 'cap-halt' | 'wall-halt' | 'step-stalled' | `step-red:<id>`
187
+ * 'interpreter-red' | 'cap-halt' | 'wall-halt' | 'step-stalled' |
188
+ * 'hitl-pause' | 'hitl-decision-red' | `step-red:<id>`
147
189
  */
148
- 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 }) {
190
+ export async function runJob(rawSpec, { approvals, workdir, provider, nativeProvider, providerFor, judgeProvider = null, 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, heldRuling = null, reviewDoor = null, doorRerun = null }) {
149
191
  // 0. the ledger's counters, declared FIRST so that every job-end — including
150
192
  // the pre-token reds below — can state a real figure. An omitted `spentUsd` is
151
193
  // not a zero: a consumer reads `undefined` and either crashes or launders it
@@ -156,6 +198,11 @@ export async function runJob(rawSpec, { approvals, workdir, provider, nativeProv
156
198
  // number that enters the arbiter's arithmetic: a garbage fold is 0, never a NaN
157
199
  // that would poison every later comparison into "no cap".
158
200
  let spentUsd = typeof priorSpentUsd === 'number' && Number.isFinite(priorSpentUsd) && priorSpentUsd > 0 ? priorSpentUsd : 0;
201
+ /** F103's money half — the CHAIN's fold, kept as its own figure so `spentUsd`
202
+ * (the chain total, what a person reads and what the ceiling governs) and THIS
203
+ * ENGAGEMENT's spend can be stated side by side on every terminal. Two things
204
+ * counted by one number will eventually be asked to disagree. */
205
+ const chainFoldUsd = spentUsd;
159
206
  let unpriced = false;
160
207
  // RESUME (v1.46 §3): was the FOLD itself exact? `readResume` marks it false when any
161
208
  // round inside the dead attempt came back unpriced, and that unknown does not heal by
@@ -195,7 +242,16 @@ export async function runJob(rawSpec, { approvals, workdir, provider, nativeProv
195
242
  // ONE spelling of the four causes, so the terminal and the in-flight money readout can
196
243
  // never disagree about whether the same run's figure is exact.
197
244
  const spendComplete = () => !unpriced && !stalled && !cutMidCall && !priorFloor;
198
- const spend = () => ({ spentUsd, spendComplete: spendComplete() });
245
+ const spend = () => ({
246
+ spentUsd,
247
+ spendComplete: spendComplete(),
248
+ // F103's second counter, on every terminal beside the chain total it is NOT.
249
+ // `spentUsd` is cumulative-so-far (this job, across engagements) and governs the
250
+ // ceiling; this is what THIS engagement spent. Always emitted, never conditional
251
+ // on there being a fold: a consumer that has to branch on field presence is a
252
+ // consumer that will read absence as zero.
253
+ engagementSpentUsd: Math.max(0, spentUsd - chainFoldUsd),
254
+ });
199
255
  // 1. human-signs-always — before ANY provider call (N1 decision #1)
200
256
  if (!checkApproval(rawSpec, approvals)) {
201
257
  emit('job-end', { outcome: 'unapproved-spec', detail: 'no approval record matches this exact spec version', ...spend() });
@@ -208,6 +264,11 @@ export async function runJob(rawSpec, { approvals, workdir, provider, nativeProv
208
264
  return 'job-red';
209
265
  }
210
266
  const job = /** @type {any} */ (jv.job);
267
+ /** F103 — the two TIME figures, resolved once here and read the same way by
268
+ * `runPlan`'s clock. An unreadable ruling resolves to `fresh: false` and folds as
269
+ * it always did; `runPlan` is where it is refused, so this never has to. */
270
+ const chainWallMs = typeof priorWallMs === 'number' && Number.isFinite(priorWallMs) && priorWallMs > 0 ? priorWallMs : 0;
271
+ const wallFold = resolveHumanRuling(humanRuling, heldRuling, doorRerun).fresh ? 0 : chainWallMs;
211
272
  emit('job-start', {
212
273
  job: job.job, specHash: jobSpecHash(job), budgetUsd: job.budgetUsd,
213
274
  shape: 'plan', goal: job.goal,
@@ -220,7 +281,17 @@ export async function runJob(rawSpec, { approvals, workdir, provider, nativeProv
220
281
  // A fold of $0 that is NOT exact is still a fold — the unknown is the whole of what
221
282
  // it inherited, and it is the only field that can carry it forward.
222
283
  ...(spentUsd > 0 || priorFloor ? { priorSpentUsd: spentUsd, priorSpendComplete: !priorFloor } : {}),
223
- ...(typeof priorWallMs === 'number' && Number.isFinite(priorWallMs) && priorWallMs > 0 ? { priorWallMs } : {}),
284
+ // …and the TIME fold, which F103 splits in two. `priorWallMs` is what the next
285
+ // reader ADDS to (the bound), and it is engagement-scoped: a rerun opens a fresh
286
+ // engagement, so the minutes the person did not commission are not a bound on
287
+ // anybody. `chainWallMs` is the cumulative view and is declared whenever there is
288
+ // one, so nothing is lost — the readout keeps spanning the chain (the halt readout
289
+ // and the leg's own governor answer different questions and are never mixed).
290
+ // The `fresh` reading comes from the ONE resolver `runPlan`'s clock reads, never a
291
+ // second spelling of "is this a rerun" (that is how a record and a clock come to
292
+ // disagree about the same leg).
293
+ ...(wallFold > 0 ? { priorWallMs: wallFold } : {}),
294
+ ...(chainWallMs > 0 && chainWallMs !== wallFold ? { chainWallMs } : {}),
224
295
  // the REPLAN ledger, on the same declaration and for the same reason — a chain of
225
296
  // resumes must fold once. This is the DIRECT-spine half of it: `readResume`'s
226
297
  // `direct` mode opens its one implicit window on `job-start` and reads the fold off
@@ -259,7 +330,9 @@ export async function runJob(rawSpec, { approvals, workdir, provider, nativeProv
259
330
  // `account` reds the run on ANY unpriced round (a null cost is the honest
260
331
  // unknown, never $0 — F6): per-round metering means a partially-unpriced run
261
332
  // is caught natively, round by round, with no separate unpricedRounds tally.
262
- if (type === 'worker-round') account(/** @type {any} */ (data)?.costUsd);
333
+ // …and the CLOSE's own rounds sum into the same wallet the moment a close can
334
+ // spend at all (the judged floor): one ledger, never a second arithmetic.
335
+ if (ACCOUNTED_ROUND_TYPES.includes(type)) account(/** @type {any} */ (data)?.costUsd);
263
336
  // W1: a `stall` is the plan flow announcing it ABANDONED a call and reissued
264
337
  // it. The abandoned call's spend is unknowable from here (billed or not,
265
338
  // indistinguishable), so from this point the priced sum is a floor no matter
@@ -283,7 +356,7 @@ export async function runJob(rawSpec, { approvals, workdir, provider, nativeProv
283
356
  // accounts it natively (F12) and the job-end money contract is unchanged.
284
357
  {
285
358
  const outcome = await runPlan(job, {
286
- workdir, provider, nativeProvider, providerFor, emit: meter, capRuns, ...(strikeLimit !== undefined ? { strikeLimit } : {}), closeTimeoutMs, layerRoot, bridge, priorWallMs, resumeSeed, resumeGrades, resumeReplans, resumeBranch,
359
+ workdir, provider, nativeProvider, providerFor, judgeProvider, emit: meter, capRuns, ...(strikeLimit !== undefined ? { strikeLimit } : {}), closeTimeoutMs, layerRoot, bridge, priorWallMs: chainWallMs, resumeSeed, resumeGrades, resumeReplans, resumeBranch, humanRuling, heldRuling, reviewDoor, doorRerun, priorSpentUsd: chainFoldUsd,
287
360
  remainingUsd: () => Math.min(shellCapUsd, job.budgetUsd - spentUsd),
288
361
  isUnpriced: () => unpriced, // F6: let the plan flow bail in-flight, not just after it returns
289
362
  spendComplete, // …and let its money-halt readout say whether the remaining it quotes is exact
package/src/selection.js CHANGED
@@ -1,5 +1,5 @@
1
1
  // Layer 3 — D3's DISPLAY half: the workflow LISTING, and the prompt that asks for a
2
- // pick (design record docs/plans/2026-08-01-layer-3-reuse-design.md, D3).
2
+ // pick (design record docs/02-features/2026-08-01-layer-3-reuse-design.md, D3).
3
3
  //
4
4
  // hamr's ruling, verbatim: *"i think there should be a listing for workflows names that
5
5
  // user can choose or auto-choose, or do we want llm to always decide (we still need to
@@ -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')}`