bareloop 0.23.0 → 0.25.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.
package/src/authorflow.js CHANGED
@@ -80,7 +80,7 @@ import { JUDGE_RULE_IDS } from './judged.js';
80
80
  import {
81
81
  KIND_CATALOGUE, MAX_STAGES, DIRECTIONS, BASELINES,
82
82
  GENRE_LANGUAGES, TYPES_GENRE_TEMPLATE, classGuards, genreEnv, genreOwnedEnvNames, genreInstruments,
83
- validateDeclaration, envCapableKind, classMenu, VERDICT_CLASSES, LIVE_CLASSES,
83
+ validateDeclaration, envCapableKind, classMenu, VERDICT_CLASSES, LIVE_CLASSES, MENU_CLASSES,
84
84
  } from './authoring.js';
85
85
  import { buildSeedListing, cleanEntry, SURVEY_CAUSES, AUTHOR_CALL_TIMEOUT_MS } from './authorscout.js';
86
86
  import { extractArtifact, priceOf, scrubRaw, tallyCalls, capStop } from './text.js';
@@ -1418,18 +1418,28 @@ export async function authorClose({
1418
1418
  // what D5 exists to make impossible. `verb` rides as a structured field so the
1419
1419
  // caller can carry this to the counted admission path rather than re-deriving
1420
1420
  // the demand from prose.
1421
- if (!LIVE_CLASSES.includes(String(verdictType))) {
1421
+ // MENU_CLASSES, not LIVE_CLASSES (PRD item 31.1): this is the AUTHORING path,
1422
+ // and the question it asks is "may a NEW close be drafted for this class",
1423
+ // never "can this class run". `hitl` answers no to the first and yes to the
1424
+ // second — it is unlisted, not locked, so `validateCloseDecl` still validates an
1425
+ // existing hitl close against its real battery and the runtime still executes it.
1426
+ if (!MENU_CLASSES.includes(String(verdictType))) {
1422
1427
  const known = VERDICT_CLASSES.includes(String(verdictType));
1428
+ const unlisted = LIVE_CLASSES.includes(String(verdictType));
1423
1429
  return refuse([{
1424
1430
  code: 'class-unsupported',
1425
1431
  path: 'verdictType',
1426
1432
  verb: String(verdictType),
1427
1433
  lib: 'bareloop',
1428
- detail: known
1429
- ? `"${verdictType}" is a named but LOCKED verdict class — v1 admits ${LIVE_CLASSES.join(', ')} only, and a `
1430
- + 'class with no guard battery must refuse rather than author a close without the guards it cannot supply'
1431
- : `"${verdictType}" is not a verdict class — the menu is ${VERDICT_CLASSES.join(' | ')}, and nothing derives `
1432
- + 'a class from the answers',
1434
+ detail: unlisted
1435
+ ? `"${verdictType}" is built and still runs, but it is NOT ON THE AUTHORING MENU — this flow drafts `
1436
+ + `${MENU_CLASSES.join(', ')} only. Its class moved to fwdloop; an existing close of this class still `
1437
+ + 'validates and still executes, but no new one is drafted here'
1438
+ : known
1439
+ ? `"${verdictType}" is a named but LOCKED verdict class — v1 admits ${MENU_CLASSES.join(', ')} only, and a `
1440
+ + 'class with no guard battery must refuse rather than author a close without the guards it cannot supply'
1441
+ : `"${verdictType}" is not a verdict class — the menu is ${VERDICT_CLASSES.join(' | ')}, and nothing derives `
1442
+ + 'a class from the answers',
1433
1443
  }], 'precheck');
1434
1444
  }
1435
1445
 
package/src/authoring.js CHANGED
@@ -108,6 +108,38 @@ export const LOCKED_CLASSES = Object.freeze([]);
108
108
  /** the classes v1 actually builds */
109
109
  export const LIVE_CLASSES = Object.freeze(VERDICT_CLASSES.filter((c) => !LOCKED_CLASSES.includes(c)));
110
110
 
111
+ /** OFF THE MENU, but still built — a THIRD state, distinct from both lists above
112
+ * (PRD item 31.1, hamr 2026-09-09: *"retire hitl from list, keep its code"*, and
113
+ * *"if it's not on menu, you can't reach it, from menu"*).
114
+ *
115
+ * `hitl` was retired by ruling at v1.71 and the class moved to fwdloop (PRD item
116
+ * 29), but no constant was ever updated, so `runInterview` still returned
117
+ * `ok:true` for a hitl pick — an authorable job for a class this product no
118
+ * longer closes.
119
+ *
120
+ * Why this is NOT `LOCKED_CLASSES`. Locked means INEXPRESSIBLE: no guard battery
121
+ * exists, so `validateCloseDecl` cannot check the close and must refuse
122
+ * (`src/declaredclose.js:306`). hitl's battery, question set, composition rule and
123
+ * runtime all exist and are tested — locking it would make every one of them
124
+ * unreachable, which is not "keeping the code", it is abandoning it in place with
125
+ * its tests deleted. So the split is:
126
+ *
127
+ * LOCKED_CLASSES — no battery. Cannot be validated, cannot run. (empty today)
128
+ * UNLISTED_CLASSES — battery exists, validates and RUNS. Just never OFFERED.
129
+ * LIVE_CLASSES — everything the system can validate and run (locked removed).
130
+ *
131
+ * Read by the AUTHORING surface only (`runInterview`, `authorClose`'s precheck).
132
+ * The spec validator, the close validator and the runtime deliberately do NOT
133
+ * read it: a hitl spec that already exists still validates and still runs, which
134
+ * is what keeps the code alive and under test. Nothing can AUTHOR a new one.
135
+ * @type {readonly string[]} */
136
+ export const UNLISTED_CLASSES = Object.freeze(['hitl']);
137
+
138
+ /** what the authoring interview actually OFFERS: live, minus unlisted. This is
139
+ * "the menu" in hamr's sentence, and it is the only list an authoring refusal
140
+ * should ever name. */
141
+ export const MENU_CLASSES = Object.freeze(LIVE_CLASSES.filter((c) => !UNLISTED_CLASSES.includes(c)));
142
+
111
143
  /** the hierarchy as a comparable number. `green` is the FLOOR: a mechanical
112
144
  * measurement is the cheapest honest verdict, a judge is above it, a person is
113
145
  * above that. Read by the ceiling rule and by nothing else. */
package/src/authorjob.js CHANGED
@@ -35,11 +35,19 @@
35
35
  // same way `repoPath` does — a closed set handed over enumerated, never read out
36
36
  // of prose.
37
37
  //
38
- // v1 STILL ADMITS ONLY `green`. A soft-green or hitl pick returns the honest
39
- // counted refusal on the `request-red` admission path (D13's mechanism), now
40
- // carrying demand for a VERDICT CLASS rather than only for a genre. It refuses
41
- // at ADMISSION — before that class's questions are ever asked, because those two
42
- // question sets are named but locked (`QUESTION_SETS`, M3).
38
+ // THE MENU IS TWO SHAPES: `green` (a command decides done) and `soft-green` (a
39
+ // rubric decides it, through the judged floor). Both are LIVE and both author
40
+ // here. `hitl` is named by the radio, still validates and still runs, and is NOT
41
+ // OFFERED — the class moved to fwdloop (PRD items 29 and 31.1); picking it
42
+ // returns the honest counted refusal on the `request-red` admission path (D13's
43
+ // mechanism), carrying demand for a VERDICT CLASS rather than only for a genre.
44
+ // It refuses at ADMISSION — before that class's questions are ever asked.
45
+ //
46
+ // This comment said the opposite until 2026-09-09 (*"v1 STILL ADMITS ONLY
47
+ // `green`"*), which was false for a long time and sent one build in the wrong
48
+ // direction: `LOCKED_CLASSES` was empty, so `runInterview` returned `ok:true` for
49
+ // every class including the retired one. Check `MENU_CLASSES` at runtime rather
50
+ // than trusting any sentence here, this one included.
43
51
  //
44
52
  // AND THE INTERVIEW KEYS ON THE CLASS. Three frozen question sets replace one
45
53
  // set per genre: genres are a fat tail that cannot be enumerated, classes are
@@ -91,14 +99,15 @@
91
99
  // in the stored declaration (forward-compat point 3), and the suite pins it.
92
100
 
93
101
  import { jobSpecHash, validateJob } from './job.js';
94
- import { seedAtHead, seedListing, seedRead as runSeedRead, makeSeedTrees, SEED_EXEMPT_KINDS, JUDGED_FLOOR_KIND } from './kinds.js';
102
+ import { seedAtHead, seedListing, seedRead as runSeedRead, makeSeedTrees, SEED_EXEMPT_KINDS, judgedStages, closeJudges } from './kinds.js';
95
103
  // SOFTGREEN modules 4 and 5 — the compile the interview's Q6/Q7 answers land in,
96
104
  // and the gate that decides whether the ruler they describe is a ruler.
97
105
  import { proposeJudgedArtifacts, signJudgedArtifacts, foldJudgedArtifacts } from './cardauthor.js';
98
106
  import { runCalibration } from './calibrate.js';
99
- import { JUDGE_MODEL, CALIBRATION_SIZE } from './judged.js';
107
+ import { CALIBRATION_SIZE } from './judged.js';
100
108
  import {
101
109
  GENRE_LANGUAGES, LOCKED_KINDS, TYPES_GENRE, VERDICT_CLASSES, LOCKED_CLASSES, LIVE_CLASSES,
110
+ UNLISTED_CLASSES, MENU_CLASSES,
102
111
  } from './authoring.js';
103
112
  import { QUESTION_SETS, questionsFor, requiredAnswersFor, authorClose, makeCostBook } from './authorflow.js';
104
113
  import { runAuthorScout, buildSeedListing, SCOUT_ATTEMPTS } from './authorscout.js';
@@ -123,7 +132,7 @@ export { QUESTION_SETS, questionsFor, requiredAnswersFor };
123
132
  * rule about it: illegal becomes inexpressible instead of rejected after the
124
133
  * fact (the `check-passes(name)` model). A UI renders it as three buttons, and
125
134
  * two of them are marked locked. */
126
- export { VERDICT_CLASSES, LOCKED_CLASSES, LIVE_CLASSES };
135
+ export { VERDICT_CLASSES, LOCKED_CLASSES, LIVE_CLASSES, UNLISTED_CLASSES, MENU_CLASSES };
127
136
 
128
137
  /** The territory every refusal here lands against. bareloop's OWN catalogue —
129
138
  * the genre menu, the verdict menu, the kind menu — never a bare-suite package.
@@ -141,6 +150,15 @@ const LEVERS = ([
141
150
  'wait for the verdict-classes rung — this refusal is the evidence it waits on',
142
151
  ]);
143
152
 
153
+ /** the levers for an UNLISTED class (PRD item 31.1): the class is BUILT, so
154
+ * "wait for the rung" is the wrong sentence — there is no rung left to wait on.
155
+ * The honest next step is the other product, or a class this one still offers. */
156
+ /** @type {string[]} */
157
+ const UNLISTED_LEVERS = ([
158
+ 'restate the job for a class bareloop still offers: green (a command decides done) or soft-green (a rubric does)',
159
+ 'take a job that needs a person\'s sign-off to fwdloop — that product is built for the human-in-the-loop shape',
160
+ ]);
161
+
144
162
  /**
145
163
  * One refusal, in the shape both the caller and the ledger need.
146
164
  * @param {{verb: string|null, path: string, detail: string, kind?: 'request-red'|'decision-ready', options?: string[]}} o
@@ -236,17 +254,33 @@ export function runInterview({ answers, verdictType = null, repoPath = null, que
236
254
  return out({});
237
255
  }
238
256
 
239
- // A LOCKED class refuses HERE, before its questions are asked. Counted demand
240
- // for the class itself — the verdict-classes rung is what this evidence is for.
241
- if (LOCKED_CLASSES.includes(picked)) {
257
+ // A class that is not ON THE MENU refuses HERE, before its questions are asked.
258
+ // Counted demand for the class itself — the verdict-classes rung is what this
259
+ // evidence is for. TWO reasons a pick lands here, and they are different facts
260
+ // (PRD item 31.1):
261
+ // * LOCKED — no guard battery exists, so nothing could validate the close.
262
+ // * UNLISTED — the class is built and still RUNS; this product just no longer
263
+ // offers it. `hitl` moved to fwdloop (item 29); its code, tests
264
+ // and runtime stay, and an existing hitl spec still validates.
265
+ // Either way the person cannot author one, and the refusal says which it is
266
+ // rather than pooling two different situations into one sentence.
267
+ if (LOCKED_CLASSES.includes(picked) || UNLISTED_CLASSES.includes(picked)) {
268
+ const unlisted = UNLISTED_CLASSES.includes(picked);
242
269
  return out({
243
270
  refusal: refuse({
244
271
  verb: picked,
245
272
  path: 'verdictType',
246
- detail: `You picked "${picked}", and v1 cannot close that yet: it admits ${LIVE_CLASSES.join(', ')} only — a `
247
- + 'job whose done a command can decide. A judged score and a person\'s sign-off are named in the menu and '
248
- + 'locked, so picking one is recorded as demand rather than dropped, and this refusal is the evidence the '
249
- + 'verdict-classes rung waits on.',
273
+ options: unlisted ? UNLISTED_LEVERS : LEVERS,
274
+ detail: unlisted
275
+ ? `You picked "${picked}", and this product no longer offers that class: the menu is `
276
+ + `${MENU_CLASSES.join(', ')} — a job whose done a command can decide, or one whose done needs `
277
+ + 'judgement against a rubric. A person\'s sign-off moved to fwdloop, which is built for it. The '
278
+ + 'class itself is not deleted — an existing spec still validates and still runs — so picking one is '
279
+ + 'recorded as demand rather than dropped.'
280
+ : `You picked "${picked}", and v1 cannot close that yet: it admits ${MENU_CLASSES.join(', ')} only — a `
281
+ + 'job whose done a command can decide. A class with no guard battery is named in the menu and locked, '
282
+ + 'so picking one is recorded as demand rather than dropped, and this refusal is the evidence the '
283
+ + 'verdict-classes rung waits on.',
250
284
  }),
251
285
  });
252
286
  }
@@ -277,21 +311,32 @@ export function runInterview({ answers, verdictType = null, repoPath = null, que
277
311
  }
278
312
  if (reds.length) return out({ answers: given });
279
313
 
280
- // The green pick's own precondition: D1's "a repo is never a precondition"
281
- // holds for the INTERVIEW and not for D9's validity gates, all three of which
282
- // rest on a runnable patient with a git seed. A doc or a website job has no
283
- // seed and no changed set, so nothing deterministic can be measured against it
284
- // — that is softgreen/hitl territory, out of v1, refused on the same counted
314
+ // A GENRE precondition, not a class one — and the difference is why this text
315
+ // was wrong until 2026-09-09 (PRD item 31.2). It used to answer a missing repo
316
+ // with "that needs a judged (soft-green) or a human close", which told a person
317
+ // who had just PICKED soft-green that they needed soft-green. Both live classes
318
+ // land here identically: D1's "a repo is never a precondition" holds for the
319
+ // INTERVIEW and not for D9's validity gates, all three of which rest on a
320
+ // runnable patient with a git seed, and the close CATALOGUE is code-genre only
321
+ // (doc-genre kinds are not admitted — PRD item 26). So a doc or a website job
322
+ // refuses for want of a SEED, whichever class it picked, on the same counted
285
323
  // path. The class the user picked cannot be honoured, so it is not returned.
324
+ //
325
+ // The `verb` stays `non-green-verdict` deliberately. It is the ledger's key for
326
+ // this demand and renaming it would split one running count into two that look
327
+ // like different demands; the name is a poor description of a genre refusal,
328
+ // and that is a smaller cost than a discontinuous ledger.
286
329
  if (!isNonEmptyString(repoPath)) {
287
330
  return out({
288
331
  answers: given,
289
332
  refusal: refuse({
290
333
  verb: 'non-green-verdict',
291
334
  path: 'repoPath',
292
- detail: 'This job has no code repository, so there is no seed to measure against and no changed set to read — '
293
- + 'nothing deterministic can decide whether it came back done. That needs a judged (soft-green) or a '
294
- + 'human close; this authoring flow drafts code-genre closes against a git seed only.',
335
+ detail: 'This job has no code repository, so there is no seed to measure against and no changed set to '
336
+ + 'read. This authoring flow drafts CODE-GENRE closes against a git seed only, and that is true of both '
337
+ + 'classes it offers: a green close runs commands over the tree, and a soft-green close judges the '
338
+ + 'artifacts the run changed in it. A job with no repository needs a doc-genre close, which is not built '
339
+ + 'here yet.',
295
340
  }),
296
341
  });
297
342
  }
@@ -370,7 +415,7 @@ function composerRefusal(reds) {
370
415
  * @param {{answers: Record<string|number, any>, repoPath?: string|null, lang: string,
371
416
  * verdictType?: string|null,
372
417
  * questions?: Record<string|number, string>|null, generate?: Function, provider?: any,
373
- * seedRef?: string|null, scout?: any, listing?: any, ceilingUsd?: number|null,
418
+ * seedRef?: string|null, scout?: any, listing?: any, ceilingUsd?: number|null, judgeModel?: string|null,
374
419
  * onPhase?: (phase: string, data?: any) => void,
375
420
  * onCall?: (call: {label: string, costUsd: number|null, unpricedRounds: number}) => void,
376
421
  * seedFn?: Function, scoutFn?: Function, listingFn?: Function,
@@ -384,6 +429,11 @@ export async function authorCloseForJob({
384
429
  answers, repoPath = null, lang, verdictType = null, questions = null,
385
430
  generate, provider = null, seedRef = null, scout = null, listing = null,
386
431
  ceilingUsd = null,
432
+ // THE JUDGE IDENTITY (PRD item 32.1), required only on the path that actually
433
+ // composes a judged stage — the stored calibration set carries the judge that
434
+ // certified it, and there is no library pin to fall back to. Absent on a purely
435
+ // mechanical close is correct and costs nothing: that close never judges.
436
+ judgeModel = null,
387
437
  // THE PROGRESS SEAMS. This composition is the pipeline's long silence — a real
388
438
  // survey, a real declaration ladder, and a real toolchain per close stage — and
389
439
  // it emitted nothing until it returned, so an operator watching a live run
@@ -573,7 +623,7 @@ export async function authorCloseForJob({
573
623
  // as the caller-error guard it is.
574
624
  /** @type {any} */
575
625
  let judged = null;
576
- if ((closeDecl.stages ?? []).some((/** @type {any} */ s) => isObj(s) && s.kind === JUDGED_FLOOR_KIND)) {
626
+ if (closeJudges(closeDecl)) {
577
627
  // ONE CEILING, ALL THREE PAID SEAMS. The declaration loop's own calls are
578
628
  // ABSORBED before the compile is asked for, so the ceiling folds in prior
579
629
  // spend and re-invoking a seam cannot silently widen it. `absorb` deliberately
@@ -620,7 +670,14 @@ export async function authorCloseForJob({
620
670
  // non-array before anything can be `ok` — and the narrowing is stated rather
621
671
  // than defaulted: `?? []` here would fold an EMPTY signed set into a close,
622
672
  // which is the one direction that must never be reachable.
623
- closeDecl = foldJudgedArtifacts(closeDecl, { card: signed.card, cases: /** @type {any[]} */ (signed.cases) });
673
+ if (typeof judgeModel !== 'string' || judgeModel.trim() === '') {
674
+ throw new Error('[authorjob] authorCloseForJob composed a JUDGED close and was given no judgeModel (PRD item '
675
+ + '32.1). The stored calibration set carries the judge that certified it, and a set stamped with a model '
676
+ + 'nobody named is exactly the unattributable floor that field exists to prevent.');
677
+ }
678
+ closeDecl = foldJudgedArtifacts(closeDecl, {
679
+ card: signed.card, cases: /** @type {any[]} */ (signed.cases), judgeModel,
680
+ });
624
681
  return {
625
682
  ok: true,
626
683
  refusal: null,
@@ -760,12 +817,15 @@ const scrubRed = (r) => /** @type {any} */ (Object.fromEntries(
760
817
  * The book is built HERE and PRIOR SPEND IS ABSORBED into it, so the ceiling folds in
761
818
  * what the earlier seams already spent and re-invoking a seam cannot silently widen
762
819
  * it; the gate reads it between calls and stops as a refusal that names money.
763
- * @param {{spec: any, judgedStages: any[], judgeLoop: Function|null,
820
+ * `judgeModel` is the RESOLVED judge identity (PRD item 32.1), required and with no
821
+ * default: it is stamped onto the graded set and onto the calibration-missing record
822
+ * alike, so both say which judge the close would have been certified by.
823
+ * @param {{spec: any, judgedStages: any[], judgeLoop: Function|null, judgeModel: string,
764
824
  * onJudgeCost: ((c: any) => void)|null, calibrateFn: Function,
765
825
  * ceilingUsd: number|null, priorCalls: any[]}} o
766
826
  * @returns {Promise<{ok: boolean, record: any, reds: Red[], refusal: Refusal|null}>}
767
827
  */
768
- async function calibrationGate({ spec, judgedStages, judgeLoop, onJudgeCost, calibrateFn, ceilingUsd, priorCalls }) {
828
+ async function calibrationGate({ spec, judgedStages, judgeLoop, judgeModel, onJudgeCost, calibrateFn, ceilingUsd, priorCalls }) {
769
829
  const cases = spec.closeDecl?.calibration?.cases ?? null;
770
830
  // ONE card, and it is the SIGNED one — the card the calibration is graded
771
831
  // against must be the card the close will RUN, or the gate certifies a ruler
@@ -779,7 +839,7 @@ async function calibrationGate({ spec, judgedStages, judgeLoop, onJudgeCost, cal
779
839
  + 'ruler is unmeasured, and an unmeasured ruler cannot be signed.';
780
840
  return {
781
841
  ok: false,
782
- record: { ok: false, stop: 'calibration-missing', judgeModel: JUDGE_MODEL, required: CALIBRATION_SIZE },
842
+ record: { ok: false, stop: 'calibration-missing', judgeModel, required: CALIBRATION_SIZE },
783
843
  reds: [{ code: 'calibration-missing', path: 'closeDecl.calibration.cases', detail }],
784
844
  refusal: refuse({
785
845
  kind: 'decision-ready',
@@ -805,6 +865,7 @@ async function calibrationGate({ spec, judgedStages, judgeLoop, onJudgeCost, cal
805
865
  cases,
806
866
  card,
807
867
  judgeLoop,
868
+ judgeModel,
808
869
  // the gate's own calls join the same tally the ceiling is read against — a
809
870
  // ceiling that cannot see the spend it bounds is F45's blind detector in a
810
871
  // money coat. The caller's reporter fires with the call's whole record, after.
@@ -942,8 +1003,8 @@ async function calibrationGate({ spec, judgedStages, judgeLoop, onJudgeCost, cal
942
1003
  *
943
1004
  * @param {{spec: any, workdir: string, seedRef?: string|null, shellCapUsd?: number,
944
1005
  * timeoutMs?: number, seedFn?: Function, listingFn?: Function, seedReadFn?: Function,
945
- * judgeLoop?: Function|null, onJudgeCost?: ((c: any) => void)|null, calibrateFn?: Function,
946
- * ceilingUsd?: number|null, priorCalls?: any[]}} o
1006
+ * judgeLoop?: Function|null, judgeModel?: string|null, onJudgeCost?: ((c: any) => void)|null,
1007
+ * calibrateFn?: Function, ceilingUsd?: number|null, priorCalls?: any[]}} o
947
1008
  * @returns {Promise<{ok: boolean, specHash: string|null, seedRef: string|null,
948
1009
  * gates: any, work: any[], guards: any[], stops: any[], reds: Red[], refusal: Refusal|null}>}
949
1010
  */
@@ -953,8 +1014,11 @@ export async function prepareSigning({
953
1014
  // THE PAID SEAM, and it is the operator's to wire — this module owns no
954
1015
  // provider and picks none (the same contract `authorCloseForJob` keeps for the
955
1016
  // drafting model). Absent is never a fall-back: a judged close whose gate could
956
- // not run is refused, not waved through.
957
- judgeLoop = null, onJudgeCost = null, calibrateFn = runCalibration,
1017
+ // not run is refused, not waved through. `judgeModel` is the seam's other half
1018
+ // (PRD item 32.1) — WHICH model that loop drives, resolved by the caller
1019
+ // (`resolveJudge`, src/judged.js). The two travel together: a gate that grades
1020
+ // without recording its judge certifies a floor nobody can attribute.
1021
+ judgeLoop = null, judgeModel = null, onJudgeCost = null, calibrateFn = runCalibration,
958
1022
  // THE OPERATOR'S CEILING, and the spend it has already met. `null`/absent is
959
1023
  // UNBOUNDED and is a stated choice, never a default invented here (the same
960
1024
  // contract `authorCloseForJob` keeps). `priorCalls` is what the earlier paid
@@ -1109,9 +1173,17 @@ export async function prepareSigning({
1109
1173
  // validator checks a stored set for LEGALITY only and deliberately allows a
1110
1174
  // judged stage with no set at all, because making it mandatory is a signing
1111
1175
  // decision and this is the signing gate.
1112
- const judgedStages = (spec.closeDecl.stages ?? []).filter((/** @type {any} */ s) => isObj(s) && s.kind === JUDGED_FLOOR_KIND);
1113
- if (judgedStages.length) {
1114
- const cal = await calibrationGate({ spec, judgedStages, judgeLoop, onJudgeCost, calibrateFn, ceilingUsd, priorCalls });
1176
+ const judged = judgedStages(spec.closeDecl);
1177
+ if (judged.length) {
1178
+ if (judgeLoop !== null && (typeof judgeModel !== 'string' || judgeModel.trim() === '')) {
1179
+ throw new Error('[authorjob] prepareSigning was wired a judgeLoop with no judgeModel (PRD item 32.1). The two '
1180
+ + 'halves of the judge seam travel together: the graded set is stamped with the judge that certified it, and '
1181
+ + 'there is no library pin to fall back to.');
1182
+ }
1183
+ const cal = await calibrationGate({
1184
+ spec, judgedStages: judged, judgeLoop, judgeModel: /** @type {string} */ (judgeModel),
1185
+ onJudgeCost, calibrateFn, ceilingUsd, priorCalls,
1186
+ });
1115
1187
  base.gates.calibration = cal.record;
1116
1188
  if (!cal.ok) return { ...base, work, guards, reds: cal.reds, refusal: cal.refusal };
1117
1189
  }
package/src/calibrate.js CHANGED
@@ -46,7 +46,9 @@
46
46
  // calibration becomes a refusal, exactly as D9's other three gates work.
47
47
  // * IT PICKS NO NUMBER. The size is `CALIBRATION_SIZE` (hamr's), the floor is
48
48
  // all-of-them (hamr's), the ladder is `JUDGE_ATTEMPTS` (operator-confirmed),
49
- // and the model is `JUDGE_MODEL` (pinned). Nothing here invents a threshold.
49
+ // and the model is the run's RESOLVED judge identity, handed in (PRD item
50
+ // 32.1) rather than pinned in this library. Nothing here invents a threshold
51
+ // and nothing here picks a judge.
50
52
  // * IT OWNS NO PROVIDER. The judge seam arrives as `judgeLoop`; an absent one
51
53
  // is a WIRING GAP that stops the gate, never a silent skip — a judged close
52
54
  // signed off a calibration nobody ran is the exact state this gate exists to
@@ -58,7 +60,7 @@
58
60
 
59
61
  import { createHash } from 'node:crypto';
60
62
  import {
61
- JUDGE_MODEL, LOCATE_LABEL, LOCATE_AXES, CALIBRATION_SIZE,
63
+ LOCATE_LABEL, LOCATE_AXES, CALIBRATION_SIZE,
62
64
  runLocate, decide, expectedOf, validateCard, validateCalibrationSet,
63
65
  } from './judged.js';
64
66
  import { JUDGE_ATTEMPTS } from './kinds.js';
@@ -309,11 +311,11 @@ export function factsResist(facts, gold, artifactText) {
309
311
  * when the model is malforming, which is the worst moment for a paid ladder to be
310
312
  * unbounded. A stop comes back as its own field rather than as a `red`: a ceiling
311
313
  * is the OPERATOR's governance, never the transport's casualty.
312
- * @param {{artifactText: string, card: any, judgeLoop: Function, attempts: number,
314
+ * @param {{artifactText: string, card: any, judgeLoop: Function, judgeModel: string, attempts: number,
313
315
  * meter: (c: any) => void, id: string, kind: string,
314
316
  * capStop: () => 'cap-halt'|'pricing-red'|null}} o
315
317
  */
316
- async function pipeOnce({ artifactText, card, judgeLoop, attempts, meter, id, kind, capStop }) {
318
+ async function pipeOnce({ artifactText, card, judgeLoop, judgeModel, attempts, meter, id, kind, capStop }) {
317
319
  /** @type {any} */
318
320
  let last = null;
319
321
  /** @type {any[]} */
@@ -341,7 +343,7 @@ async function pipeOnce({ artifactText, card, judgeLoop, attempts, meter, id, ki
341
343
  // when ITS OWN wall is unbounded (`PROVIDER_TIMEOUT_MS`, src/clock.js).
342
344
  callBounds: { timeoutMs: PROVIDER_TIMEOUT_MS },
343
345
  onCost: (c) => meter({
344
- id, kind, attempt, label: CALIBRATION_LABEL, model: JUDGE_MODEL,
346
+ id, kind, attempt, label: CALIBRATION_LABEL, model: judgeModel,
345
347
  costUsd: c.costUsd, unpricedRounds: c.unpricedRounds,
346
348
  }),
347
349
  });
@@ -381,7 +383,13 @@ async function pipeOnce({ artifactText, card, judgeLoop, attempts, meter, id, ki
381
383
  * graded one: `ok` is false, the cases nobody bought are absent rather than recorded
382
384
  * as failures, and `casualty` stays null — the transport was fine, the operator's own
383
385
  * governance stopped the gate.
384
- * @param {{cases: any, card: any, judgeLoop: Function|null,
386
+ * `judgeModel` is REQUIRED and has no default (PRD item 32.1). It is the identity
387
+ * the caller resolved (`resolveJudge`, src/judged.js) and it is what the graded set
388
+ * is STAMPED with — `setHash` folds it in, so a set certified by one judge cannot be
389
+ * mistaken for the same set certified by another. A default here would be the silent
390
+ * fall-back the whole judge-identity change exists to remove: a gate that grades
391
+ * under a model nobody named certifies a floor nobody can attribute.
392
+ * @param {{cases: any, card: any, judgeLoop: Function|null, judgeModel: string,
385
393
  * onCost?: (c: any) => void, attempts?: number, battery?: any[], batteryCard?: any,
386
394
  * capStop?: () => 'cap-halt'|'pricing-red'|null}} o
387
395
  * @returns {Promise<{ok: boolean, stop: string|null, casualty: any,
@@ -392,12 +400,25 @@ async function pipeOnce({ artifactText, card, judgeLoop, attempts, meter, id, ki
392
400
  * costUsd: number|null, knownUsd: number, spendComplete: boolean, calls: any[]}>}
393
401
  */
394
402
  export async function runCalibration({
395
- cases, card, judgeLoop, onCost = () => {},
403
+ cases, card, judgeLoop, judgeModel, onCost = () => {},
396
404
  attempts = JUDGE_ATTEMPTS,
397
405
  battery = /** @type {any[]} */ (/** @type {unknown} */ (INJECTION_LOCATE_BATTERY)),
398
406
  batteryCard = INJECTION_CARD,
399
407
  capStop = () => null,
400
408
  }) {
409
+ // The mandatory-identity throw guards a REAL judge seam graded under nobody's
410
+ // name — it does not apply when there is no seam at all. A caller wiring no
411
+ // `judgeLoop` (an adopter who has not connected a judge yet) legitimately hands
412
+ // in no `judgeModel` either, and that case is the WIRING-GAP `no-judge` stop
413
+ // below, returned as a red rather than thrown. Throwing here ahead of that
414
+ // check would make the wiring-gap stop unreachable through its own documented
415
+ // path — `prepareSigning` (src/authorjob.js) draws exactly this same line with
416
+ // its own `judgeLoop !== null` guard, and this function has to agree with it.
417
+ if (typeof judgeLoop === 'function' && (typeof judgeModel !== 'string' || judgeModel.trim() === '')) {
418
+ throw new Error('[calibrate] runCalibration needs the resolved judgeModel (PRD item 32.1): the graded set is '
419
+ + 'STAMPED with the judge that certified it, and a set nobody can attribute is a floor nobody can tell apart '
420
+ + 'from one graded by a different model. There is no library pin to fall back to.');
421
+ }
401
422
  /** @type {any[]} */
402
423
  const calls = [];
403
424
  /** @param {any} c */
@@ -413,7 +434,7 @@ export async function runCalibration({
413
434
  return /** @type {any} */ ({
414
435
  ok: false, stop: null, casualty: null, graded: [], failures: [],
415
436
  injection: { styles: [], allResisted: false, leaks: [] },
416
- reds: [], judgeModel: JUDGE_MODEL,
437
+ reds: [], judgeModel,
417
438
  cardHash: null, casesHash: null, setHash: null,
418
439
  costUsd: t.costUsd, knownUsd: t.knownUsd, spendComplete: t.spendComplete, calls: calls.map((c) => ({ ...c })),
419
440
  ...extra,
@@ -464,7 +485,7 @@ export async function runCalibration({
464
485
  code: 'no-judge-seam',
465
486
  path: 'judgeLoop',
466
487
  detail: `the calibration gate runs ${CALIBRATION_SIZE} real locate calls plus ${battery.length} injection `
467
- + `artifacts through ${JUDGE_MODEL}, and no judge seam was wired. An absent seam is an adopter wiring gap and `
488
+ + `artifacts through ${judgeModel}, and no judge seam was wired. An absent seam is an adopter wiring gap and `
468
489
  + 'never a fall-back: a gate that grades nothing would certify every close it was asked about',
469
490
  }],
470
491
  });
@@ -472,7 +493,7 @@ export async function runCalibration({
472
493
 
473
494
  const cardHash = artifactHash(card);
474
495
  const casesHash = artifactHash(cases);
475
- const setHash = artifactHash({ card, cases, judgeModel: JUDGE_MODEL });
496
+ const setHash = artifactHash({ card, cases, judgeModel });
476
497
  const stamped = { cardHash, casesHash, setHash };
477
498
 
478
499
  // ── the signed ten ────────────────────────────────────────────────────────
@@ -480,7 +501,7 @@ export async function runCalibration({
480
501
  const graded = [];
481
502
  for (const c of cases) {
482
503
  const r = await pipeOnce({
483
- artifactText: String(c.artifact), card, judgeLoop: /** @type {Function} */ (judgeLoop),
504
+ artifactText: String(c.artifact), card, judgeLoop: /** @type {Function} */ (judgeLoop), judgeModel,
484
505
  attempts, meter, id: String(c.id), kind: 'case', capStop,
485
506
  });
486
507
  // the ceiling, ahead of the casualty branch: a call nobody funded is not a call
@@ -527,7 +548,7 @@ export async function runCalibration({
527
548
  const styles = [];
528
549
  for (const b of battery) {
529
550
  const r = await pipeOnce({
530
- artifactText: b.artifact, card: batteryCard, judgeLoop: /** @type {Function} */ (judgeLoop),
551
+ artifactText: b.artifact, card: batteryCard, judgeLoop: /** @type {Function} */ (judgeLoop), judgeModel,
531
552
  attempts, meter, id: b.id, kind: 'injection', capStop,
532
553
  });
533
554
  if (r.budget) {
package/src/cardauthor.js CHANGED
@@ -60,10 +60,10 @@
60
60
  // pipe produces are one shape read twice (module 5 compares them mechanically).
61
61
 
62
62
  import {
63
- JUDGE_RULE_IDS, JUDGE_RULES, CALIBRATION_SIZE, CASE_VERDICTS, validateJudgedArtifacts, JUDGE_MODEL,
63
+ JUDGE_RULE_IDS, JUDGE_RULES, CALIBRATION_SIZE, CASE_VERDICTS, validateJudgedArtifacts,
64
64
  } from './judged.js';
65
65
  import { askStructured, makeCostBook } from './authorflow.js';
66
- import { JUDGED_FLOOR_KIND } from './kinds.js';
66
+ import { judgedStages } from './kinds.js';
67
67
  import { isObj, redactSecrets } from './validate.js';
68
68
 
69
69
  /** @typedef {{code: string, path: string, detail: string, [k: string]: any}} Red */
@@ -363,15 +363,16 @@ export function signJudgedArtifacts({ proposal, fix = null }) {
363
363
  * - THE CASES land beside the stages as `calibration.cases` — a close-level
364
364
  * artifact, because the set calibrates the RULER and not one stage's params,
365
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).
366
+ * - THE JUDGE MODEL lands beside them as `calibration.judgeModel`. Since PRD item
367
+ * 32.1 that is the RESOLVED judge identity the caller hands in (`resolveJudge`,
368
+ * src/judged.js — the spec's signed `judge` override, else the job's own worker
369
+ * model), not a library pin. A calibration set is only worth the judge that
370
+ * graded it, so the set has to carry which judge certified it or nothing
371
+ * downstream can tell a signed floor from a moved one. Storing it HERE, inside
372
+ * the spec, is what makes a judge change flip `jobSpecHash`: the signature dies,
373
+ * the signer re-signs, and a re-sign is a re-run of the calibration gate. It is
374
+ * REQUIRED, with no default — a set stamped with a model nobody named is exactly
375
+ * the unattributable floor this field exists to prevent (F45).
375
376
  *
376
377
  * A card with NOWHERE TO LAND THROWS. It is a caller's own broken input — a
377
378
  * signed rubric folded into a close with no judged ruler — and there is no safe
@@ -380,20 +381,26 @@ export function signJudgedArtifacts({ proposal, fix = null }) {
380
381
  * The input is never mutated: a closeDecl is a signed artefact and rewriting a
381
382
  * caller's copy in place is how two readers come to disagree about what was
382
383
  * signed.
383
- * @param {any} closeDecl @param {{card: any, cases: any[]}} o
384
+ * @param {any} closeDecl @param {{card: any, cases: any[], judgeModel: string}} o
384
385
  * @returns {any} a new closeDecl
385
386
  */
386
- export function foldJudgedArtifacts(closeDecl, { card, cases }) {
387
+ export function foldJudgedArtifacts(closeDecl, { card, cases, judgeModel }) {
388
+ if (typeof judgeModel !== 'string' || judgeModel.trim() === '') {
389
+ throw new Error('[cardauthor] foldJudgedArtifacts needs the resolved judgeModel (PRD item 32.1): the stored set '
390
+ + 'carries the judge that certified it, and there is no library pin to fall back to.');
391
+ }
387
392
  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);
393
+ // `judgedStages` filters `out`'s OWN stage objects, so the card assignment
394
+ // below mutates the clone in place — the reason this reads `out`, not the
395
+ // caller's `closeDecl`.
396
+ const judged = judgedStages(out);
390
397
  if (judged.length !== 1) {
391
398
  throw new Error(`[cardauthor] the signed card folds into the close's ONE judged-floor stage, and this close has `
392
399
  + `${judged.length} — a rubric with nowhere to land is signed evidence nothing reads, and a second judged stage `
393
400
  + 'is a second bar for one verdict');
394
401
  }
395
402
  judged[0].params = { ...(isObj(judged[0].params) ? judged[0].params : {}), card: structuredClone(card) };
396
- out.calibration = { cases: structuredClone(cases), judgeModel: JUDGE_MODEL };
403
+ out.calibration = { cases: structuredClone(cases), judgeModel };
397
404
  return out;
398
405
  }
399
406