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,479 @@
1
+ // THE TWO SIGNED ARTIFACTS — softgreen module 4 (design record
2
+ // docs/02-features/2026-08-17-softgreen-review-door-design.md §4.3 and §4.4,
3
+ // plus the 2026-08-18 threshold addendum).
4
+ //
5
+ // Module 1 built the judged floor's core, module 2 wired the stage into a close,
6
+ // module 3 opened the class and asked the two extra questions. Nothing compiled
7
+ // their answers. This module does exactly that and nothing more:
8
+ //
9
+ // Q6 → THE RUBRIC CARD (§4.3) — the person's own pass/fail lines, as
10
+ // ENUMERATED items over the arbiter's own rulebook.
11
+ // Q7 → THE CALIBRATION SET (§4.4) — TEN cases, frozen, that the whole pipe
12
+ // must grade before the close is signable.
13
+ //
14
+ // ── THE D5 PATH, AND WHY IT IS THIS ONE ─────────────────────────────────────
15
+ //
16
+ // Both artifacts are SHOWN AND FIXED: an LLM proposes the extractable rewrite of
17
+ // what the person said, the SIGNER fixes it, and THE FIXED VERSION IS WHAT IS
18
+ // STORED — enumerated inside the spec hash, so a card line or a case is a re-sign
19
+ // exactly like every other spec edit. Asking the person to write the rubric
20
+ // themselves is the SWE tax this product refuses; letting the model's draft ride
21
+ // unsigned is the arbiter authored by a model. The signer in the middle is the
22
+ // whole design.
23
+ //
24
+ // ── WHAT THIS MODULE DOES NOT DO ────────────────────────────────────────────
25
+ //
26
+ // * IT DOES NOT GRADE. The calibration GATE — running LOCATE and `decide()`
27
+ // over the ten and refusing a signature on anything short of 10/10 with
28
+ // itemized reds, alongside the upstream INJECTION_BATTERY — is module 5. What
29
+ // is built here is what that gate reads.
30
+ // * IT STORES NO INJECTION CASES. The battery is upstream's and it is module
31
+ // 5's to run; a copy of it stored here would be a second inventory of
32
+ // somebody else's evidence.
33
+ // * IT PICKS NO NUMBER. `CALIBRATION_SIZE` is hamr's own ruling, verbatim
34
+ // (*"go build 10, we could double later"*). It is operator territory and a
35
+ // size change is a spec-level threshold change — the standing
36
+ // no-agent-threshold-picking rule binds here exactly as everywhere else.
37
+ // * IT ADMITS NO DOC-GENRE ARTIFACT. A case is code-genre text, because the
38
+ // catalogue is code-genre only.
39
+ //
40
+ // ── THE CASE SHAPE, AND WHY EACH FIELD EXISTS ───────────────────────────────
41
+ //
42
+ // { id, artifact, expect: { verdict: "pass"|"red", reds: [{ rule, fn }] } }
43
+ //
44
+ // `artifact` REAL TEXT — a file excerpt from the patient or a realistic
45
+ // snippet the signer edits. It is what LOCATE will be pointed at,
46
+ // so it is the artifact and never a description of one.
47
+ // `verdict` what `decide()` must render. Two values, both reachable.
48
+ // `reds` the ITEMIZED expectation, and the floor is itemized reds rather
49
+ // than verdict-match: a pipe that reds the right case for the wrong
50
+ // reason has not been calibrated, it has been lucky. `rule` selects
51
+ // from the CARD's own lines (a case cannot expect a red from a line
52
+ // the card never named — §4.3's ceiling, made mechanical), and `fn`
53
+ // names the function the red lands on, which is the address
54
+ // `decide()` itself reports.
55
+ //
56
+ // WHAT IS DELIBERATELY NOT STORED: the red's `why` and its `quote`. Both are the
57
+ // judge's own prose about a specific emission, and a signer cannot predict either
58
+ // — pinning them would fail an honest pipe on wording. `expectedOf` is the one
59
+ // spelling of that reduction, so the shape the signer stores and the shape the
60
+ // pipe produces are one shape read twice (module 5 compares them mechanically).
61
+
62
+ import {
63
+ JUDGE_RULE_IDS, JUDGE_RULES, CALIBRATION_SIZE, CASE_VERDICTS, validateJudgedArtifacts, JUDGE_MODEL,
64
+ } from './judged.js';
65
+ import { askStructured, makeCostBook } from './authorflow.js';
66
+ import { JUDGED_FLOOR_KIND } from './kinds.js';
67
+ import { isObj, redactSecrets } from './validate.js';
68
+
69
+ /** @typedef {{code: string, path: string, detail: string, [k: string]: any}} Red */
70
+
71
+ // ── THE NUMBERS, ALL OPERATOR-SET ───────────────────────────────────────────
72
+ //
73
+ // `CALIBRATION_SIZE` and `CASE_VERDICTS` are imported from `src/judged.js`, never
74
+ // re-spelled: what a legal set IS belongs to the arbiter beside the rulebook, and
75
+ // a second copy of the size here is a second threshold to keep in step.
76
+
77
+ /** the output channel for the compile. It takes no action; it carries the answer. */
78
+ export const PROPOSAL_TOOL_NAME = 'propose_rubric';
79
+ /** what the tool returns. The loop stops on delivery, so the model never reads it. */
80
+ export const PROPOSAL_ACK = 'proposal received';
81
+ /** the label the compile call is metered and persisted under — one spelling */
82
+ export const PROPOSAL_LABEL = 'judged-compile';
83
+ /** a malformed EMISSION is a transport fault, bounded on its own axis — the same
84
+ * ceiling `authorClose` declares for the declaration channel */
85
+ export const MAX_PROPOSAL_RETRIES = 2;
86
+
87
+ // ── 1. THE SCHEMA — the closed sets, handed over ────────────────────────────
88
+
89
+ /**
90
+ * The proposal's shape, and therefore the whole shape the compiler can express.
91
+ *
92
+ * `rule` is an ENUM off the judge's own rulebook in BOTH places it appears — the
93
+ * card's lines and a case's expected reds — because wherever only a closed set
94
+ * can satisfy a field, the illegal value is made INEXPRESSIBLE rather than
95
+ * rejected after the fact (the `check-passes(name)` model). The SIZE is pinned by
96
+ * `minItems`/`maxItems` from the one constant, so nine and eleven are both
97
+ * unsayable rather than argued about afterwards.
98
+ *
99
+ * `artifact` and `text` are the only free strings, and neither decides anything:
100
+ * one is the code the judge will read, the other is the signer's own words.
101
+ */
102
+ export function proposalSchema() {
103
+ const ruleEnum = { enum: [...JUDGE_RULE_IDS], description: 'which owned rule this line asserts' };
104
+ return {
105
+ type: 'object',
106
+ properties: {
107
+ card: {
108
+ type: 'object',
109
+ description: 'the rubric, as enumerated items: one line per thing the person actually looks for, in the '
110
+ + 'order they look for them.',
111
+ properties: {
112
+ items: {
113
+ type: 'array',
114
+ minItems: 1,
115
+ items: {
116
+ type: 'object',
117
+ properties: {
118
+ rule: ruleEnum,
119
+ text: { type: 'string', minLength: 1, description: 'what this line means, in the person\'s own words' },
120
+ },
121
+ required: ['rule', 'text'],
122
+ additionalProperties: false,
123
+ },
124
+ },
125
+ },
126
+ required: ['items'],
127
+ additionalProperties: false,
128
+ },
129
+ cases: {
130
+ type: 'array',
131
+ minItems: CALIBRATION_SIZE,
132
+ maxItems: CALIBRATION_SIZE,
133
+ description: `exactly ${CALIBRATION_SIZE} calibration cases, and at least one of each verdict — a set that `
134
+ + 'cannot fail one way proves nothing',
135
+ items: {
136
+ type: 'object',
137
+ properties: {
138
+ id: { type: 'string', minLength: 1, description: 'a unique lowercase-hyphenated slug' },
139
+ artifact: {
140
+ type: 'string',
141
+ minLength: 1,
142
+ description: 'the REAL source text this case is about — what the judge will actually read, never a '
143
+ + 'description of it',
144
+ },
145
+ expect: {
146
+ type: 'object',
147
+ properties: {
148
+ verdict: { enum: [...CASE_VERDICTS] },
149
+ reds: {
150
+ type: 'array',
151
+ description: 'the itemized expectation: empty for a pass, and one entry per card line the artifact '
152
+ + 'violates for a red',
153
+ items: {
154
+ type: 'object',
155
+ properties: {
156
+ rule: ruleEnum,
157
+ fn: { type: 'string', minLength: 1, description: 'the function the red lands on' },
158
+ },
159
+ required: ['rule', 'fn'],
160
+ additionalProperties: false,
161
+ },
162
+ },
163
+ },
164
+ required: ['verdict', 'reds'],
165
+ additionalProperties: false,
166
+ },
167
+ },
168
+ required: ['id', 'artifact', 'expect'],
169
+ additionalProperties: false,
170
+ },
171
+ },
172
+ },
173
+ required: ['card', 'cases'],
174
+ additionalProperties: false,
175
+ };
176
+ }
177
+
178
+ /**
179
+ * The output channel. It records and acknowledges; it takes no action, which is
180
+ * why handing it to a toolless compile call does not reopen D3.
181
+ * @param {{calls: any[]}} box
182
+ */
183
+ export function proposalTool(box) {
184
+ return {
185
+ name: PROPOSAL_TOOL_NAME,
186
+ description: `Deliver the rubric card and the ${CALIBRATION_SIZE} calibration cases. Call this exactly once.`,
187
+ parameters: proposalSchema(),
188
+ execute: async (/** @type {any} */ args) => { box.calls.push(args); return PROPOSAL_ACK; },
189
+ };
190
+ }
191
+
192
+ // ── 2. THE PROMPT ───────────────────────────────────────────────────────────
193
+
194
+ /**
195
+ * The system prompt a caller BINDS ITS SEAM WITH — this module owns no provider
196
+ * and picks none, exactly as `authorClose` does not: the compile call arrives as
197
+ * `generate`, and a caller wires it with `makeLoopGenerate(provider, {system:
198
+ * COMPILE_SYSTEM})`, whose own `maxTokens` default (F30) is what holds ten
199
+ * artifacts. Exported rather than inlined so the one register this call is made
200
+ * in is not re-spelled at every integration.
201
+ */
202
+ export const COMPILE_SYSTEM = 'You compile a person\'s own words into a rubric a deterministic arbiter can run. You '
203
+ + 'never decide whether anything passes, you never write code that runs, and you never invent a rule: the rulebook '
204
+ + 'is fixed and you select from it.';
205
+
206
+ /**
207
+ * The compile prompt. Q6's and Q7's answers travel VERBATIM — they are the whole
208
+ * input, and paraphrasing a lay answer is how a rubric stops being the person's.
209
+ *
210
+ * The rulebook is rendered from `JUDGE_RULES` itself rather than restated, for
211
+ * the reason the catalogue block is rendered from the catalogue: the prompt and
212
+ * the gate must describe one set of facts.
213
+ * @param {{answers: Record<string|number, any>, questions?: Record<string|number, string>}} o
214
+ */
215
+ export function cardCasesPrompt({ answers, questions = {} }) {
216
+ const q6 = questions[6] ?? 'When you judge the result yourself, what separates a pass from a fail?';
217
+ const q7 = questions[7] ?? 'Give one example you\'d pass and one you\'d fail, and say why.';
218
+ const rules = JUDGE_RULE_IDS.map((id) => `- ${id}\n the facts it reads: ${JUDGE_RULES[id].ask.trim().split('\n')[0].trim()}`);
219
+
220
+ return `You are compiling ONE person's answers into the two artifacts a judged close is signed with.
221
+
222
+ They are not a software engineer. They described what they look for in their own words, and your whole job is to
223
+ express that over the rulebook below — never to add a standard they did not state, and never to drop one they did.
224
+
225
+ THEIR ANSWERS — verbatim, and the only input you have
226
+
227
+ Q6. ${q6}
228
+ A6. ${answers?.[6] ?? '(no answer given)'}
229
+
230
+ Q7. ${q7}
231
+ A7. ${answers?.[7] ?? '(no answer given)'}
232
+
233
+ ---
234
+
235
+ THE RULEBOOK — the only rules that exist. You SELECT from it; a rule that is not here cannot be asked for.
236
+
237
+ ${rules.join('\n')}
238
+
239
+ ---
240
+
241
+ THE CARD
242
+
243
+ One item per thing they actually look for, in the order they look for them. \`rule\` is selected from the rulebook;
244
+ \`text\` is what that line means IN THEIR WORDS. The judge only ever checks lines this card names, so a standard you
245
+ leave out is a standard nobody checks — and a standard they never stated is one you must not add.
246
+
247
+ THE CALIBRATION SET
248
+
249
+ Exactly ${CALIBRATION_SIZE} cases. Each one is REAL SOURCE TEXT — a small, realistic file the judge could be pointed
250
+ at — plus what the arbiter must decide about it.
251
+
252
+ - \`artifact\` is the code itself, never a description of it.
253
+ - \`expect.verdict\` is "pass" when the artifact satisfies every card line, "red" when it violates any.
254
+ - \`expect.reds\` is EMPTY for a pass, and for a red it names one entry per violation: the card \`rule\` it breaks
255
+ and the \`fn\` (function name) it breaks it on.
256
+
257
+ AT LEAST ONE PASS AND AT LEAST ONE RED. A set that can only fail one way measures nothing.
258
+ Every rule you name in a case must be a rule your card carries.
259
+
260
+ HOW TO ANSWER
261
+
262
+ Call the ${PROPOSAL_TOOL_NAME} tool exactly once, with the card and the ${CALIBRATION_SIZE} cases as its arguments.
263
+ Do not write them as text.`;
264
+ }
265
+
266
+ // ── 3. THE COMPILE CHANNEL ──────────────────────────────────────────────────
267
+
268
+ /** the compile's own channel, over the SHARED bounded ladder (`askStructured`) */
269
+ const proposalChannel = () => ({
270
+ name: PROPOSAL_TOOL_NAME,
271
+ instruction: `Your reply did not deliver exactly one proposal through the ${PROPOSAL_TOOL_NAME} tool. `
272
+ + `Call ${PROPOSAL_TOOL_NAME} exactly once, with the card and the ${CALIBRATION_SIZE} cases as its arguments. `
273
+ + 'Do not write them as text.',
274
+ textPath: 'proposal',
275
+ tool: (/** @type {{calls: any[]}} */ box) => proposalTool(box),
276
+ });
277
+
278
+ // ── 4. SHOWN AND FIXED ──────────────────────────────────────────────────────
279
+
280
+ /** every free string in the two artifacts, scrubbed at INGEST — an answer, a
281
+ * proposed snippet and a signer's edit all become a SIGNED artefact that outlives
282
+ * the run, and a file that captures a key captures it forever. The ONE shared
283
+ * inventory, never a second one.
284
+ *
285
+ * AND IT ANNOUNCES. A scrub that silently rewrites bytes leaves a signer reading
286
+ * one artifact on screen and signing another on disk — the same failure
287
+ * `applyGenreEnv` refuses by returning its `dropped` list rather than quietly
288
+ * deleting a model-authored env var. Every altered value is reported by PATH (the
289
+ * path only: echoing the matched secret into the announcement would be the same
290
+ * leak, one hop on), so the caller can say *this was masked* out loud.
291
+ * @param {any} v @param {string} at @param {{path: string}[]} altered */
292
+ function scrubDeep(v, at, altered) {
293
+ if (typeof v === 'string') {
294
+ const masked = redactSecrets(v);
295
+ // `redactSecrets` returns a non-matching string byte-identical, so this
296
+ // compares equal on every honest value and costs the clean path nothing
297
+ if (masked !== v) altered.push({ path: at });
298
+ return masked;
299
+ }
300
+ if (Array.isArray(v)) return v.map((x, i) => scrubDeep(x, `${at}[${i}]`, altered));
301
+ if (isObj(v)) return Object.fromEntries(Object.entries(v).map(([k, x]) => [k, scrubDeep(x, at ? `${at}.${k}` : k, altered)]));
302
+ return v;
303
+ }
304
+
305
+ /**
306
+ * THE D5 MOMENT. The proposal is SHOWN; the signer's `fix` is what is STORED.
307
+ *
308
+ * The fix replaces the proposal WHOLE rather than merging into it. A merge would
309
+ * mean a signer who deleted a card line got it back, and "the signer signs the
310
+ * whole hash" is not a rule that survives a field-wise reconciliation. Handing
311
+ * back no fix is a legal answer — signing the proposal as proposed — and it says
312
+ * so (`source`), because *a person read this and changed nothing* and *nobody has
313
+ * looked* are two different facts.
314
+ *
315
+ * THE GATE RUNS OVER WHAT WILL BE STORED, never over the proposal: a signer can
316
+ * fix a legal proposal into an illegal one, and the artifact that reaches the
317
+ * hash is the one that must be legal.
318
+ *
319
+ * `scrubbed` is the INGEST SCRUB'S OWN ANNOUNCEMENT — the paths whose bytes the
320
+ * secret inventory masked on the way in. Empty on every honest artifact, and
321
+ * never silent when it is not: a signer signs a hash over the STORED bytes, so
322
+ * "what you typed is not what was stored" is a fact they are owed rather than one
323
+ * the redactor keeps.
324
+ * @param {{proposal: any, fix?: any}} o
325
+ * @returns {{ok: boolean, reds: Red[], card: any, cases: any[]|null, source: 'signer'|'proposal',
326
+ * scrubbed: {path: string}[]}}
327
+ */
328
+ export function signJudgedArtifacts({ proposal, fix = null }) {
329
+ const source = /** @type {'signer'|'proposal'} */ (fix === null || fix === undefined ? 'proposal' : 'signer');
330
+ const chosen = source === 'signer' ? fix : proposal;
331
+ if (!isObj(chosen)) {
332
+ return {
333
+ ok: false,
334
+ reds: [{ code: 'invalid-value', path: 'proposal', detail: 'the signed artifacts are an object {card, cases}' }],
335
+ card: null,
336
+ cases: null,
337
+ source,
338
+ scrubbed: [],
339
+ };
340
+ }
341
+ // A COPY, scrubbed. The caller keeps its proposal (the run's own audit of what
342
+ // the model said); what is stored can never be reached back through it.
343
+ /** @type {{path: string}[]} */
344
+ const scrubbed = [];
345
+ const card = scrubDeep(structuredClone(/** @type {any} */ (chosen).card ?? null), 'card', scrubbed);
346
+ const cases = scrubDeep(structuredClone(/** @type {any} */ (chosen).cases ?? null), 'cases', scrubbed);
347
+ const v = validateJudgedArtifacts({ card, cases });
348
+ return { ok: v.ok, reds: v.reds, card, cases: Array.isArray(cases) ? cases : null, source, scrubbed };
349
+ }
350
+
351
+ // ── 5. THE FOLD — enumerated into the signed spec ───────────────────────────
352
+
353
+ /**
354
+ * Fold the two signed artifacts into the close declaration, ENUMERATED.
355
+ *
356
+ * WHERE EACH LANDS, and both are inside the spec so `jobSpecHash` covers them by
357
+ * construction (the hash is over the whole resolved spec):
358
+ * - THE CARD replaces the judged stage's own `params.card`. ONE card, and it is
359
+ * the SIGNED one: the composer's draft is a draft, and two cards in one close
360
+ * is the drift a single storage site exists to prevent. The stage is where it
361
+ * already lives, which is what `validateCard`, the M2 runner and the locate
362
+ * prompt all read.
363
+ * - THE CASES land beside the stages as `calibration.cases` — a close-level
364
+ * artifact, because the set calibrates the RULER and not one stage's params,
365
+ * and because module 5's gate reads it before any stage runs.
366
+ * - THE JUDGE MODEL lands beside them as `calibration.judgeModel`, and it is the
367
+ * PIN (`JUDGE_MODEL`) rather than anything a caller may name. A calibration set
368
+ * is only worth the tier that graded it — §4.2's whole safety argument is worth
369
+ * exactly as much as BA-20's injection evidence, which is haiku-4.5's alone — so
370
+ * the set has to carry which tier certified it or nothing downstream can tell a
371
+ * signed floor from a bumped one. Storing it HERE, inside the spec, is what makes
372
+ * a bump flip `jobSpecHash`: the signature dies, the signer re-signs, and a
373
+ * re-sign is a re-run of the calibration gate. That is the docstring on
374
+ * `JUDGE_MODEL` finally given a detector instead of a promise (F45).
375
+ *
376
+ * A card with NOWHERE TO LAND THROWS. It is a caller's own broken input — a
377
+ * signed rubric folded into a close with no judged ruler — and there is no safe
378
+ * direction to degrade toward: storing it would sign a rubric nothing reads.
379
+ *
380
+ * The input is never mutated: a closeDecl is a signed artefact and rewriting a
381
+ * caller's copy in place is how two readers come to disagree about what was
382
+ * signed.
383
+ * @param {any} closeDecl @param {{card: any, cases: any[]}} o
384
+ * @returns {any} a new closeDecl
385
+ */
386
+ export function foldJudgedArtifacts(closeDecl, { card, cases }) {
387
+ const out = structuredClone(closeDecl);
388
+ const stages = Array.isArray(out?.stages) ? out.stages : [];
389
+ const judged = stages.filter((/** @type {any} */ s) => isObj(s) && s.kind === JUDGED_FLOOR_KIND);
390
+ if (judged.length !== 1) {
391
+ throw new Error(`[cardauthor] the signed card folds into the close's ONE judged-floor stage, and this close has `
392
+ + `${judged.length} — a rubric with nowhere to land is signed evidence nothing reads, and a second judged stage `
393
+ + 'is a second bar for one verdict');
394
+ }
395
+ judged[0].params = { ...(isObj(judged[0].params) ? judged[0].params : {}), card: structuredClone(card) };
396
+ out.calibration = { cases: structuredClone(cases), judgeModel: JUDGE_MODEL };
397
+ return out;
398
+ }
399
+
400
+ // ── 6. THE PAID SEAM ────────────────────────────────────────────────────────
401
+
402
+ /**
403
+ * ONE compile call: the person's Q6 and Q7 in, a proposal out.
404
+ *
405
+ * It buys ONE artifact through ONE channel, and the malformed-emission ladder is
406
+ * the SHARED one (`askStructured`) rather than a second copy — a second ladder is
407
+ * a second place for the cap, the reject block and the re-ask to drift.
408
+ *
409
+ * WHAT IS AND IS NOT RETRIED, and the split is the standing one: a reply that
410
+ * carries no proposal is a TRANSPORT fault and is retried under the bound; a
411
+ * proposal that PARSED and then broke the ruling is not — the model answered
412
+ * perfectly clearly and re-asking buys another call to be told the same thing.
413
+ * The proposal is returned as it was said, never repaired: no JSON repair, ever.
414
+ *
415
+ * The book is the CALLER's, so one ceiling governs the survey, the declaration
416
+ * ladder and this compile — the advertised ceiling and the enforced ceiling are
417
+ * the same ceiling.
418
+ * @param {{answers: Record<string|number, any>, questions?: Record<string|number, string>,
419
+ * generate: Function, book?: ReturnType<typeof makeCostBook>,
420
+ * structuredMode?: 'tool'|'text', structureRetries?: number}} o
421
+ * @returns {Promise<{ok: boolean, proposal: any, reds: Red[], stop: string|null,
422
+ * attempts: number, raw: string, cost: any, raws: any[]}>}
423
+ */
424
+ export async function proposeJudgedArtifacts({
425
+ answers, questions = {}, generate,
426
+ book = makeCostBook({}),
427
+ structuredMode = 'tool',
428
+ structureRetries = MAX_PROPOSAL_RETRIES,
429
+ }) {
430
+ // TIGHTEN-ONLY, floor 0, exactly as `authorClose` clamps its own two axes: a
431
+ // caller may buy fewer malformed-emission retries, never more than the ceiling
432
+ // this module declares and the suite pins.
433
+ const retries = Math.max(0, Math.min(Math.trunc(Number(structureRetries)) || 0, MAX_PROPOSAL_RETRIES));
434
+ const prompt = cardCasesPrompt({ answers, questions });
435
+ const ask = await askStructured({
436
+ messages: [{ role: 'user', content: prompt }],
437
+ generate,
438
+ mode: structuredMode,
439
+ retries,
440
+ label: PROPOSAL_LABEL,
441
+ book,
442
+ channel: proposalChannel(),
443
+ });
444
+
445
+ const base = {
446
+ proposal: ask.artifact ?? null, attempts: ask.attempts, raw: ask.raw,
447
+ cost: book.report(), raws: book.raws(),
448
+ };
449
+ if (ask.budget) {
450
+ return {
451
+ ...base,
452
+ ok: false,
453
+ stop: ask.budget,
454
+ reds: [{
455
+ code: ask.budget,
456
+ path: 'budgetUsd',
457
+ detail: `the authoring ceiling bound before the rubric compile: $${book.ceilingUsd} is spent, and the call was `
458
+ + 'never made. The stop IS the checkpoint — raise the ceiling and re-run, or read what is here and stop',
459
+ }],
460
+ };
461
+ }
462
+ if (ask.providerError) {
463
+ // a casualty, never evidence about the model — and the transport's own prose
464
+ // is scrubbed on the way into a red the caller keeps
465
+ return {
466
+ ...base,
467
+ ok: false,
468
+ stop: 'provider-red',
469
+ reds: [{ code: 'provider-red', path: PROPOSAL_LABEL, detail: redactSecrets(String(ask.providerError)) }],
470
+ };
471
+ }
472
+ if (!ask.artifact) {
473
+ return { ...base, ok: false, stop: 'artifact-red', reds: [/** @type {Red} */ (ask.red)] };
474
+ }
475
+
476
+ const v = validateJudgedArtifacts({ card: ask.artifact.card, cases: ask.artifact.cases });
477
+ if (!v.ok) return { ...base, ok: false, stop: 'proposal-invalid', reds: v.reds };
478
+ return { ...base, ok: true, stop: null, reds: [] };
479
+ }
@@ -68,11 +68,14 @@
68
68
  // once instead of once-per-invocation, but `runPlan` has a dozen return paths and
69
69
  // a leaked worktree outlives the process that made it.
70
70
 
71
- import { runStage, makeSeedTrees, STOP_FAULTS, EXIT_RED } from './kinds.js';
71
+ import { runStage, makeSeedTrees, STOP_FAULTS, EXIT_RED, JUDGED_FLOOR_KIND } from './kinds.js';
72
72
  import {
73
73
  validateDeclaration, classGuards, genreOwnedEnvNames, ungroundedGenreEnv, GENRE_LANGUAGES, TYPES_GENRE,
74
74
  VERDICT_CLASSES, LIVE_CLASSES, NEVER_OFFERED_KINDS,
75
75
  } from './authoring.js';
76
+ // the calibration set's own gate lives beside the rulebook it is graded against
77
+ // (src/judged.js) — one opinion about what a judged floor's signed artifacts are
78
+ import { validateCalibrationSet } from './judged.js';
76
79
  import { stageGap } from './ralph.js';
77
80
  import { isObj, isNonEmptyString } from './validate.js';
78
81
 
@@ -99,7 +102,14 @@ export const DECLARED_GENRES = Object.freeze([TYPES_GENRE.name]);
99
102
  * constrained to the names the genre OWNS, so it can never become an environment
100
103
  * channel of its own.
101
104
  */
102
- export const CLOSE_DECL_FIELDS = Object.freeze(['genre', 'lang', 'stages', 'notes', 'genreEnv']);
105
+ export const CLOSE_DECL_FIELDS = Object.freeze(['genre', 'lang', 'stages', 'notes', 'genreEnv', 'calibration']);
106
+
107
+ /** what a stored calibration set carries, ENUMERATED: the frozen cases and the judge
108
+ * tier that graded them. Both are inside the spec, so `jobSpecHash` covers both — which
109
+ * is the whole mechanism behind "a judge-model bump forces a full recalibration"
110
+ * (src/judged.js, `JUDGE_MODEL`): the field moves, the hash moves, the signature dies,
111
+ * and the re-sign re-runs the gate. Anything else in there is a field nobody reads. */
112
+ export const CALIBRATION_FIELDS = Object.freeze(['cases', 'judgeModel']);
103
113
 
104
114
  /**
105
115
  * The prefix M1 puts on EVERY gap line of a declared stage (its `ctx.gapKeep`).
@@ -165,15 +175,22 @@ export const HUMAN_CHECKPOINTS = Object.freeze([HITL_PAUSE]);
165
175
  * validator reads one hierarchy table rather than hard-coding a class.
166
176
  *
167
177
  * `hard` is by CONSTRUCTION: every mechanical kind is a command whose measurement
168
- * is the truth. `hitl` joined it at N4 slice 1, and only because `human-confirms`
169
- * is genuinely LIVE — the widening and the kind go in one commit, or this table
170
- * admits a class the executor cannot render. The `soft` class is still absent:
171
- * `judged-floor` is locked, so no declaration can reach above hitl.
178
+ * is the truth. `hitl` joined it at N4 slice 1, and `soft` at softgreen module 3
179
+ * — each only because its kind is genuinely LIVE (`human-confirms`, then
180
+ * `judged-floor`): the widening and the kind go in one commit, or this table
181
+ * admits a class the executor cannot render.
182
+ *
183
+ * `hitl` STAYS despite the class's retirement (PRD v1.71: the human stage moved
184
+ * out of the close and became the review door at the end of every run). It is
185
+ * kept because `jobs/litectx-maintainer.json` is a SIGNED spec carrying a hitl
186
+ * declaration, and a signed spec is untouchable — dropping the spelling here
187
+ * would invalidate a signature the operator already gave, which is a re-sign
188
+ * nobody asked for. Retiring it is a separate, deliberate deletion.
172
189
  *
173
190
  * The direction that matters is unchanged. This says which class a declaration
174
191
  * MAY claim; `closeCeiling` says which class its stages actually NEED. A pick
175
192
  * below the ceiling is still the silent upgrade `class-ceiling` refuses. */
176
- export const DECLARED_CLOSE_CLASSES = Object.freeze(['hard', 'hitl']);
193
+ export const DECLARED_CLOSE_CLASSES = Object.freeze(['hard', 'soft', 'hitl']);
177
194
 
178
195
  /** @param {any} job @returns {boolean} */
179
196
  export function isDeclaredClose(job) {
@@ -186,11 +203,24 @@ export function isDeclaredClose(job) {
186
203
  *
187
204
  * `offer` and `needs` pass through untouched when a declaration carries them
188
205
  * (v1's schema does not let one), so `checkMenu`'s derivation is unchanged.
206
+ *
207
+ * THE JUDGE TIER RIDES ALONG, and it rides as a STAMP for the same reason `offer`
208
+ * does. The tier that graded this close's calibration set is a CLOSE-level fact
209
+ * (`calibration.judgeModel`), and the stage that would buy a judge call is a STAGE;
210
+ * `runStage` is handed stages and never the declaration they came out of. Rather than
211
+ * plumb a second close-level channel down through every executor seam, the one place
212
+ * that already turns a declaration into stages carries it across — and it carries it
213
+ * from the SIGNED bytes, so a stage can never name a tier the signature does not.
214
+ * `runJudgedFloor` refuses a mismatch; the absence of a stamp is the SPEC gate's
215
+ * business (`validateCloseDecl` reds a set with no model), never a runtime fall-back.
189
216
  * @param {any} closeDecl
190
217
  * @returns {any[]|null} null when this is not a declaration
191
218
  */
192
219
  export function declaredStages(closeDecl) {
193
220
  if (!isObj(closeDecl) || !Array.isArray(closeDecl.stages)) return null;
221
+ const calibrationJudgeModel = isObj(closeDecl.calibration) && isNonEmptyString(closeDecl.calibration.judgeModel)
222
+ ? closeDecl.calibration.judgeModel
223
+ : null;
194
224
  return closeDecl.stages.map((s) => (isObj(s)
195
225
  // RULING 5, stamped by the ARBITER rather than trusted from the artefact:
196
226
  // a judged or human stage is `offer: false` BY LAW, so `checkMenu` can never
@@ -198,7 +228,12 @@ export function declaredStages(closeDecl) {
198
228
  // declaration that says anything else (`human-stage-offered`), so this is
199
229
  // never overwriting a signer-visible value — it is filling in a law the
200
230
  // declaration schema deliberately gives the composer no way to state.
201
- ? { ...s, gapKeep: DECLARED_GAP_KEEP, ...(NEVER_OFFERED_KINDS.includes(s.kind) ? { offer: false } : {}) }
231
+ ? {
232
+ ...s,
233
+ gapKeep: DECLARED_GAP_KEEP,
234
+ ...(NEVER_OFFERED_KINDS.includes(s.kind) ? { offer: false } : {}),
235
+ ...(s.kind === JUDGED_FLOOR_KIND && calibrationJudgeModel !== null ? { calibrationJudgeModel } : {}),
236
+ }
202
237
  : s));
203
238
  }
204
239
 
@@ -374,6 +409,56 @@ export function validateCloseDecl(closeDecl, opts = {}) {
374
409
  });
375
410
  for (const r of v.reds) reds.push({ ...r, path: r.path ? `${at}.${r.path}` : at });
376
411
 
412
+ // ── THE FROZEN CALIBRATION SET (softgreen module 4, design §4.4). It rides
413
+ // the closeDecl rather than a stage's params because it calibrates the RULER,
414
+ // not one stage's parameters — and because it is read BEFORE any stage runs.
415
+ //
416
+ // WHAT IS CHECKED HERE is that a stored set is LEGAL: exactly the signed size,
417
+ // both polarities present, itemized reds naming lines THIS close's card
418
+ // actually carries. WHAT IS NOT is whether the pipe grades it correctly — that
419
+ // is module 5's gate, it costs paid calls, and a validator cannot buy them.
420
+ //
421
+ // A set with NO judged stage to calibrate is its own red: the card lives on
422
+ // the stage, so an orphan set is graded against nothing and would validate
423
+ // clean forever. The reverse (a judged stage with no set) is deliberately NOT
424
+ // redded here — making the set MANDATORY is the signing gate's call, and
425
+ // module 5 owns it.
426
+ if (closeDecl.calibration !== undefined) {
427
+ const judged = (Array.isArray(closeDecl.stages) ? closeDecl.stages : [])
428
+ .filter((/** @type {any} */ s) => isObj(s) && s.kind === JUDGED_FLOOR_KIND);
429
+ if (!isObj(closeDecl.calibration)) {
430
+ red('invalid-value', `${at}.calibration`, 'the frozen calibration set, as an object carrying `cases`');
431
+ } else if (judged.length !== 1) {
432
+ red('calibration-orphan', `${at}.calibration`, `this close carries a calibration set and ${judged.length} `
433
+ + 'judged-floor stage(s). The set calibrates ONE judged ruler against ONE card — with no judged stage it is '
434
+ + 'graded against nothing and would read clean forever, and with two there is no one card it belongs to',
435
+ { judgedStages: judged.length });
436
+ } else {
437
+ for (const key of Object.keys(closeDecl.calibration)) {
438
+ if (!CALIBRATION_FIELDS.includes(key)) red('invalid-value', `${at}.calibration.${key}`, `a calibration set carries ${CALIBRATION_FIELDS.map((f) => `\`${f}\``).join(' and ')} and nothing else`);
439
+ }
440
+ // WHICH TIER GRADED THIS SET, and it is REQUIRED rather than optional. A
441
+ // stored set is only worth the judge that graded it (§4.2's safety argument
442
+ // is BA-20's haiku-4.5 injection evidence and nothing else), so a set with
443
+ // no model beside it is a set nobody can attribute — and the `JUDGE_MODEL`
444
+ // docstring's promise that a bump forces recalibration has nothing to fire
445
+ // on. Required HERE and not at the stage, for the same reason the SET's own
446
+ // presence is the signing gate's call and not this validator's: this gate
447
+ // reads a stored artifact and says whether it is legal. The RUNTIME half —
448
+ // refusing to grade when the stored tier is not the tier this run would buy
449
+ // — is the judged stage's (`runJudgedFloor`), reached through the stamp
450
+ // `declaredStages` puts on the stage.
451
+ if (closeDecl.calibration.judgeModel === undefined) {
452
+ red('missing-required', `${at}.calibration.judgeModel`, 'the judge tier this set was GRADED by — a calibration '
453
+ + 'nobody can attribute to a model is a floor nobody can tell apart from a bumped one');
454
+ } else if (!isNonEmptyString(closeDecl.calibration.judgeModel)) {
455
+ red('invalid-value', `${at}.calibration.judgeModel`, 'the judge tier this set was graded by, as a non-empty string');
456
+ }
457
+ const cv = validateCalibrationSet(closeDecl.calibration.cases, { card: judged[0]?.params?.card ?? null });
458
+ for (const r of cv.reds) reds.push({ ...r, path: r.path ? `${at}.${r.path}` : `${at}.calibration` });
459
+ }
460
+ }
461
+
377
462
  const ok = reds.length === 0;
378
463
  return {
379
464
  ok,
@@ -480,11 +565,16 @@ function translate(r, redact) {
480
565
  * @param {(s: string) => string} [redact] the scrub, applied at THIS boundary
481
566
  * @param {{timeoutMs?: number, cwd?: string, seedRef?: string,
482
567
  * seedTrees?: any, gapCap?: number, maxBuffer?: number, baselineMode?: 'auto'|'worktree',
483
- * humanRuling?: {decision: string, text?: string|null}|null}} [opts]
568
+ * humanRuling?: {decision: string, text?: string|null}|null,
569
+ * judgeLoop?: ((o: {system: string}) => any)|null,
570
+ * onJudgeCost?: ((c: any) => void)|null}} [opts]
484
571
  * @returns {Promise<any>}
485
572
  */
486
573
  export async function runDeclaredStages(stages, redact = (s) => s, opts = {}) {
487
- const { timeoutMs, cwd, seedRef, seedTrees: shared, gapCap, maxBuffer, baselineMode, humanRuling = null } = opts;
574
+ const {
575
+ timeoutMs, cwd, seedRef, seedTrees: shared, gapCap, maxBuffer, baselineMode, humanRuling = null,
576
+ judgeLoop = null, onJudgeCost = null,
577
+ } = opts;
488
578
  if (!isNonEmptyString(cwd) || !isNonEmptyString(seedRef)) {
489
579
  // Refuse rather than guess. A declared close measures against a SEED; a
490
580
  // missing one is the close unable to run, not a red about the worker.
@@ -513,6 +603,14 @@ export async function runDeclaredStages(stages, redact = (s) => s, opts = {}) {
513
603
  // human stage reads the absence as "nobody has answered yet", which is the
514
604
  // whole of what a pause means.
515
605
  humanRuling,
606
+ // THE PAID SEAM and THE METER, both operator territory and both absent on
607
+ // every mechanical close. A judged stage with no seam STOPS as a wiring gap
608
+ // rather than falling back to some other model (M1 owns no provider and the
609
+ // judge tier is pinned), and every locate call it does buy is reported OUT
610
+ // through the meter so the run's ONE ledger can see it — a close has cost $0
611
+ // until this kind, and a budget must fund the attempt PLUS its close.
612
+ judgeLoop,
613
+ onJudgeCost,
516
614
  seedTrees,
517
615
  };
518
616
  try {