bareloop 0.11.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/run.js CHANGED
@@ -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
@@ -145,13 +168,26 @@ async function primitiveSmoke(workdir) {
145
168
  * RESUMES a paused run and forwarded verbatim to the plan flow. Absent on every
146
169
  * ordinary run; never authored and never defaulted (a defaulted answer to "is this
147
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.
148
184
  * @returns {Promise<string>} outcome: 'green' | 'already-green' | 'escalated' |
149
185
  * 'unapproved-spec' | 'job-red' | 'smoke-red' | 'plan-red' | 'check-red' |
150
186
  * 'close-red' | 'close-unsupported' | 'recipe-stale' | 'branch-red' | 'pricing-red' | 'provider-red' |
151
187
  * 'interpreter-red' | 'cap-halt' | 'wall-halt' | 'step-stalled' |
152
188
  * 'hitl-pause' | 'hitl-decision-red' | `step-red:<id>`
153
189
  */
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 }) {
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 }) {
155
191
  // 0. the ledger's counters, declared FIRST so that every job-end — including
156
192
  // the pre-token reds below — can state a real figure. An omitted `spentUsd` is
157
193
  // not a zero: a consumer reads `undefined` and either crashes or launders it
@@ -162,6 +198,11 @@ export async function runJob(rawSpec, { approvals, workdir, provider, nativeProv
162
198
  // number that enters the arbiter's arithmetic: a garbage fold is 0, never a NaN
163
199
  // that would poison every later comparison into "no cap".
164
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;
165
206
  let unpriced = false;
166
207
  // RESUME (v1.46 §3): was the FOLD itself exact? `readResume` marks it false when any
167
208
  // round inside the dead attempt came back unpriced, and that unknown does not heal by
@@ -201,7 +242,16 @@ export async function runJob(rawSpec, { approvals, workdir, provider, nativeProv
201
242
  // ONE spelling of the four causes, so the terminal and the in-flight money readout can
202
243
  // never disagree about whether the same run's figure is exact.
203
244
  const spendComplete = () => !unpriced && !stalled && !cutMidCall && !priorFloor;
204
- 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
+ });
205
255
  // 1. human-signs-always — before ANY provider call (N1 decision #1)
206
256
  if (!checkApproval(rawSpec, approvals)) {
207
257
  emit('job-end', { outcome: 'unapproved-spec', detail: 'no approval record matches this exact spec version', ...spend() });
@@ -214,6 +264,11 @@ export async function runJob(rawSpec, { approvals, workdir, provider, nativeProv
214
264
  return 'job-red';
215
265
  }
216
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;
217
272
  emit('job-start', {
218
273
  job: job.job, specHash: jobSpecHash(job), budgetUsd: job.budgetUsd,
219
274
  shape: 'plan', goal: job.goal,
@@ -226,7 +281,17 @@ export async function runJob(rawSpec, { approvals, workdir, provider, nativeProv
226
281
  // A fold of $0 that is NOT exact is still a fold — the unknown is the whole of what
227
282
  // it inherited, and it is the only field that can carry it forward.
228
283
  ...(spentUsd > 0 || priorFloor ? { priorSpentUsd: spentUsd, priorSpendComplete: !priorFloor } : {}),
229
- ...(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 } : {}),
230
295
  // the REPLAN ledger, on the same declaration and for the same reason — a chain of
231
296
  // resumes must fold once. This is the DIRECT-spine half of it: `readResume`'s
232
297
  // `direct` mode opens its one implicit window on `job-start` and reads the fold off
@@ -265,7 +330,9 @@ export async function runJob(rawSpec, { approvals, workdir, provider, nativeProv
265
330
  // `account` reds the run on ANY unpriced round (a null cost is the honest
266
331
  // unknown, never $0 — F6): per-round metering means a partially-unpriced run
267
332
  // is caught natively, round by round, with no separate unpricedRounds tally.
268
- 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);
269
336
  // W1: a `stall` is the plan flow announcing it ABANDONED a call and reissued
270
337
  // it. The abandoned call's spend is unknowable from here (billed or not,
271
338
  // indistinguishable), so from this point the priced sum is a floor no matter
@@ -289,7 +356,7 @@ export async function runJob(rawSpec, { approvals, workdir, provider, nativeProv
289
356
  // accounts it natively (F12) and the job-end money contract is unchanged.
290
357
  {
291
358
  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,
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,
293
360
  remainingUsd: () => Math.min(shellCapUsd, job.budgetUsd - spentUsd),
294
361
  isUnpriced: () => unpriced, // F6: let the plan flow bail in-flight, not just after it returns
295
362
  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')}`
@@ -32,12 +32,21 @@ export function schemaCoverage(catalogue?: Record<string, any>): {
32
32
  * and they arrive as validation reds through the revise loop — a schema that
33
33
  * tried to restate them would be a second validator.
34
34
  *
35
+ * `verdictType` narrows the branches to the kinds the picked class may express
36
+ * (softgreen module 3, `classMenu`): a green job cannot SPELL `judged-floor`,
37
+ * rather than spelling it and being told afterwards. COVERAGE is still computed
38
+ * over the WHOLE catalogue — the class filter must never make a parameter look
39
+ * orphaned and turn a menu narrowing into a catalogue-drift alarm.
40
+ *
35
41
  * THROWS on a catalogue it cannot cover: a malformed catalogue is a bareloop bug,
36
42
  * and failing loudly beats emitting a schema that quietly cannot say something
37
43
  * the validator will then demand.
38
44
  * @param {Record<string, any>} [catalogue]
45
+ * @param {{verdictType?: string|null}} [o]
39
46
  */
40
- export function declarationSchema(catalogue?: Record<string, any>): {
47
+ export function declarationSchema(catalogue?: Record<string, any>, { verdictType }?: {
48
+ verdictType?: string | null;
49
+ }): {
41
50
  type: string;
42
51
  properties: {
43
52
  stages: {
@@ -86,12 +95,13 @@ export function declarationSchema(catalogue?: Record<string, any>): {
86
95
  * The output channel. It records and acknowledges; it takes no action, which is
87
96
  * why handing it to a toolless authoring call does not reopen D3.
88
97
  * @param {{calls: any[]}} box
89
- * @param {{catalogue?: Record<string, any>}} [o]
98
+ * @param {{catalogue?: Record<string, any>, verdictType?: string|null}} [o]
90
99
  */
91
100
  export function declarationTool(box: {
92
101
  calls: any[];
93
- }, { catalogue }?: {
102
+ }, { catalogue, verdictType }?: {
94
103
  catalogue?: Record<string, any>;
104
+ verdictType?: string | null;
95
105
  }): {
96
106
  name: string;
97
107
  description: string;
@@ -146,9 +156,19 @@ export function declarationTool(box: {
146
156
  * The catalogue, as the authoring call sees it — rendered FROM the catalogue, so
147
157
  * the prompt and the validator judge one set of facts. `shape` and `asserts` are
148
158
  * M2's own strings.
159
+ *
160
+ * `verdictType` narrows it to the picked class's menu, exactly as it narrows the
161
+ * schema — the prompt and the tool must offer ONE set of kinds, or the composer
162
+ * reads about a kind it cannot call. A kind above the ceiling is omitted rather
163
+ * than rendered as unavailable: a LOCKED row says "this does not run in v1",
164
+ * which is a fact about the build; "not for your class" is a fact about a choice
165
+ * the person already made, and printing it invites the composer to argue with it.
149
166
  * @param {Record<string, any>} [catalogue]
167
+ * @param {{verdictType?: string|null}} [o]
150
168
  */
151
- export function catalogueBlock(catalogue?: Record<string, any>): string;
169
+ export function catalogueBlock(catalogue?: Record<string, any>, { verdictType }?: {
170
+ verdictType?: string | null;
171
+ }): string;
152
172
  /**
153
173
  * HOW THE GENRE'S OWN TOOLS PRINT — rendered FROM `genreInstruments`, so the
154
174
  * pattern the author is handed and the pattern the tree already grades with are
@@ -433,6 +453,48 @@ export function makeLoopGenerate(provider: any, { system, maxTokens }?: {
433
453
  system?: string;
434
454
  maxTokens?: number;
435
455
  }): (messages: any[], tools: any[], opts?: any) => Promise<any>;
456
+ /**
457
+ * ONE structured ask, with the malformed-emission retry bounded on its OWN axis.
458
+ * A reply that carries no artifact is an ARTIFACT-RED — name the axis, retry
459
+ * under a cap, write nothing — and it never consumes a revision, because a
460
+ * transport fault is not a defect in a close.
461
+ *
462
+ * THE CHANNEL IS A PARAMETER, and that is softgreen module 4's only change here:
463
+ * the rubric card and the calibration set are a SECOND schema-forced emission
464
+ * with the same failure modes, and a second ladder is a second place for the cap,
465
+ * the reject block and the re-ask to drift. The declaration channel below is the
466
+ * one this loop has always used, spelled out rather than inlined — nothing about
467
+ * the authoring path's behaviour moves.
468
+ * @param {{messages: any[], generate: Function, mode: 'tool'|'text', retries: number,
469
+ * label: string, book: ReturnType<typeof makeCostBook>,
470
+ * channel: {name: string, instruction: string, textPath?: string, tool: (box: {calls: any[]}) => any}}} o
471
+ * @returns {Promise<{artifact: any, attempts: number, convo: any[], raw: string,
472
+ * providerError: string|null, red: Red|null, budget: 'cap-halt'|'pricing-red'|null}>}
473
+ */
474
+ export function askStructured({ messages, generate, mode, retries, label, book, channel }: {
475
+ messages: any[];
476
+ generate: Function;
477
+ mode: "tool" | "text";
478
+ retries: number;
479
+ label: string;
480
+ book: ReturnType<typeof makeCostBook>;
481
+ channel: {
482
+ name: string;
483
+ instruction: string;
484
+ textPath?: string;
485
+ tool: (box: {
486
+ calls: any[];
487
+ }) => any;
488
+ };
489
+ }): Promise<{
490
+ artifact: any;
491
+ attempts: number;
492
+ convo: any[];
493
+ raw: string;
494
+ providerError: string | null;
495
+ red: Red | null;
496
+ budget: "cap-halt" | "pricing-red" | null;
497
+ }>;
436
498
  /**
437
499
  * THE AUTHORING FLOW.
438
500
  *
@@ -615,11 +677,50 @@ export const HITL_QUESTIONS: Readonly<{
615
677
  5: "What would make you say this came back worse than before?";
616
678
  }>;
617
679
  /**
618
- * The three sets. `questions: null` on a locked class is ABSENT, never `{}`:
619
- * an empty set would read as "this class needs no questions", which is a
620
- * different and false statement (F59's distinction, at the interview layer).
621
- * Nothing ever reads them — a locked class refuses at ADMISSION, before its
622
- * questions run — and the two readers below throw rather than hand back nothing.
680
+ * THE SOFTGREEN SET (module 3, design record §4.6) — the green questions BYTE
681
+ * FOR BYTE, plus TWO.
682
+ *
683
+ * Same discipline as the hitl set, and for the same reason: a softgreen close is
684
+ * *mechanical stages first, the judged stage after* (§4.5), so a softgreen job
685
+ * needs every mechanical fact a green one does. No renumbering of the green set,
686
+ * no genre question, nothing reworded.
687
+ *
688
+ * The two additions are the only things the green set cannot supply, and neither
689
+ * is a flourish — they are the judged floor's two REQUIRED inputs:
690
+ * - Q6 becomes the RUBRIC CARD (§4.3): the person's own pass/fail lines,
691
+ * compiled into enumerated items, signed, and enumerated in the spec hash.
692
+ * The documented ceiling rides with it — the judge catches violations of
693
+ * STATED card items only, and the fix for a miss is a new card line and a
694
+ * re-sign, never a smarter judge.
695
+ * - Q7 becomes the frozen CALIBRATION SET (§4.4): the whole pipe must grade it
696
+ * correctly before the close is signable.
697
+ * Both are asked in the person's own terms — an example they would pass and one
698
+ * they would fail, not a rubric they are asked to write — because compiling a lay
699
+ * answer into extractable items is the LLM's job (D5, proposed-and-signed) and
700
+ * asking the person to do it is the SWE tax this product refuses.
701
+ *
702
+ * What the two answers COMPILE INTO is module 4. This module carries them
703
+ * exactly as the hitl set carries its own last answer: they ride in `answers`,
704
+ * they reach the composer verbatim through the prompt's interview block, and
705
+ * nothing here derives anything from them.
706
+ */
707
+ export const SOFTGREEN_QUESTIONS: Readonly<{
708
+ 6: "When you judge the result yourself, what separates a pass from a fail? Name the few things you actually look for.";
709
+ 7: "Give one example you'd pass and one you'd fail, and say why.";
710
+ 1: "What do you want done?";
711
+ 2: "Which files or folders should change? And which files should the work read or draw from (they stay untouched)?";
712
+ 3: "Is there anything that must not change?";
713
+ 4: "How do you check today whether it's working?";
714
+ 5: "What would make you say this came back worse than before?";
715
+ }>;
716
+ /**
717
+ * The three sets, all LIVE as of softgreen module 3. The locked shape stays
718
+ * spelled out in the readers below rather than deleted with the last locked
719
+ * class: `questions: null` is ABSENT, never `{}` — an empty set would read as
720
+ * "this class needs no questions", a different and false statement (F59's
721
+ * distinction, at the interview layer) — and a locked class refuses at ADMISSION,
722
+ * before its questions run, so the two readers throw rather than hand back
723
+ * nothing. A fourth class arrives locked, and the machinery is here for it.
623
724
  * @type {Record<string, {locked: boolean, questions: Record<string|number, string>|null, required: readonly number[]|null}>}
624
725
  */
625
726
  export const QUESTION_SETS: Record<string, {
@@ -823,6 +924,43 @@ export const PARAM_SCHEMAS: Readonly<{
823
924
  requireNonEmpty: {
824
925
  const: boolean;
825
926
  };
927
+ card: {
928
+ type: string;
929
+ description: string;
930
+ properties: {
931
+ items: {
932
+ type: string;
933
+ minItems: number;
934
+ items: {
935
+ type: string;
936
+ properties: {
937
+ rule: {
938
+ enum: string[];
939
+ description: string;
940
+ };
941
+ text: {
942
+ type: string;
943
+ minLength: number;
944
+ description: string;
945
+ };
946
+ };
947
+ required: string[];
948
+ additionalProperties: boolean;
949
+ };
950
+ };
951
+ };
952
+ required: string[];
953
+ additionalProperties: boolean;
954
+ };
955
+ paths: {
956
+ type: string;
957
+ minItems: number;
958
+ maxItems: number;
959
+ items: {
960
+ type: string;
961
+ };
962
+ description: string;
963
+ };
826
964
  ask: {
827
965
  type: string;
828
966
  minLength: number;