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,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
+ }
package/src/clock.js CHANGED
@@ -43,6 +43,21 @@
43
43
  // it from the remaining budget is what stops the LAST round outliving the run.
44
44
  // 3. ~90% accuracy is the bar, by hamr's ruling (addendum 3). This module does
45
45
  // not chase precision; it reports honestly at the precision it has.
46
+ // 4. SUSPENDED MINUTES NEVER CHARGE A SIGNED WALL (hamr's ruling, 2026-08-15:
47
+ // "if computer suspended, time shouldn't count"). The default source is
48
+ // therefore MONOTONIC — `performance.now()`, which reads libuv's `uv_hrtime`
49
+ // and so CLOCK_MONOTONIC, which per clock_gettime(2) "does not count time
50
+ // that the system is suspended" on Linux (CLOCK_BOOTTIME is the one that
51
+ // would). `Date.now()` is CLOCK_REALTIME and counts every suspended minute:
52
+ // that was not a hypothetical, it charged 45 phantom minutes against a
53
+ // 45-minute wall when an s2idle suspend landed mid-run, and it is the same
54
+ // exposure for an NTP step or a hand-set date. Nothing here corrects for a
55
+ // suspend after the fact; the reading simply never includes one.
56
+ // The epoch change is safe by construction: every use of `now()` below is
57
+ // RELATIVE (`now() - startedAt`), and no value derived from this clock is
58
+ // ever published as a calendar timestamp — `report()` carries durations only,
59
+ // and the `at:`/`ts:` stamps a run writes come from their own `Date.now`
60
+ // seams (src/reuse.js), which stay calendar-correct on purpose.
46
61
  //
47
62
  // Unbounded is a REAL state, not a large number: hamr ruled `maxWallMs` has no
48
63
  // default, so a run without one is time-unbounded by explicit operator choice and
@@ -135,11 +150,14 @@ export function isWallTimeout(err, clock) {
135
150
  * run. `closeStages`: how many stages the job's close runs (W5) — each one gets
136
151
  * the FULL `closeTimeoutMs`, so this is the multiplier on the overshoot, not a
137
152
  * decoration. Defaults to 1, the single-predicate close. `now`: injected for
138
- * tests — the default is the real clock. `priorElapsedMs`: time this attempt's
139
- * PREDECESSOR already consumed (see below).
153
+ * tests — the default is the real clock, and it stays injectable precisely so
154
+ * every test here can drive time deterministically without a sleep. That default
155
+ * is MONOTONIC (fact 4 above), not `Date.now`: a wall-clock jump — a suspend, an
156
+ * NTP step, a hand-set date — must not be able to reach a signed deadline.
157
+ * `priorElapsedMs`: time this attempt's PREDECESSOR already consumed (see below).
140
158
  * @returns {Clock}
141
159
  */
142
- export function createClock({ maxWallMs = null, closeStages = 1, now = () => Date.now(), priorElapsedMs = 0 } = {}) {
160
+ export function createClock({ maxWallMs = null, closeStages = 1, now = () => performance.now(), priorElapsedMs = 0 } = {}) {
143
161
  // RESUME (module C) — hamr's checkpoint ruling in a time coat: a run killed
144
162
  // mid-try restarts THAT try under the REMAINDER of its signed per-try numbers,
145
163
  // because "a budget ceiling folds in prior spend so re-invoking cannot silently