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.
package/src/authorjob.js CHANGED
@@ -91,12 +91,17 @@
91
91
  // in the stored declaration (forward-compat point 3), and the suite pins it.
92
92
 
93
93
  import { jobSpecHash, validateJob } from './job.js';
94
- import { seedAtHead, seedListing, seedRead as runSeedRead, makeSeedTrees } from './kinds.js';
94
+ import { seedAtHead, seedListing, seedRead as runSeedRead, makeSeedTrees, SEED_EXEMPT_KINDS, JUDGED_FLOOR_KIND } from './kinds.js';
95
+ // SOFTGREEN modules 4 and 5 — the compile the interview's Q6/Q7 answers land in,
96
+ // and the gate that decides whether the ruler they describe is a ruler.
97
+ import { proposeJudgedArtifacts, signJudgedArtifacts, foldJudgedArtifacts } from './cardauthor.js';
98
+ import { runCalibration } from './calibrate.js';
99
+ import { JUDGE_MODEL, CALIBRATION_SIZE } from './judged.js';
95
100
  import {
96
101
  GENRE_LANGUAGES, LOCKED_KINDS, TYPES_GENRE, VERDICT_CLASSES, LOCKED_CLASSES, LIVE_CLASSES,
97
102
  } from './authoring.js';
98
- import { QUESTION_SETS, questionsFor, requiredAnswersFor, authorClose } from './authorflow.js';
99
- import { runAuthorScout, buildSeedListing } from './authorscout.js';
103
+ import { QUESTION_SETS, questionsFor, requiredAnswersFor, authorClose, makeCostBook } from './authorflow.js';
104
+ import { runAuthorScout, buildSeedListing, SCOUT_ATTEMPTS } from './authorscout.js';
100
105
  import {
101
106
  DECLARED_GAP_PREFIX, DECLARED_GENRES, guardNames, isDeclaredClose, validateCloseDecl,
102
107
  } from './declaredclose.js';
@@ -366,23 +371,38 @@ function composerRefusal(reds) {
366
371
  * verdictType?: string|null,
367
372
  * questions?: Record<string|number, string>|null, generate?: Function, provider?: any,
368
373
  * seedRef?: string|null, scout?: any, listing?: any, ceilingUsd?: number|null,
374
+ * onPhase?: (phase: string, data?: any) => void,
375
+ * onCall?: (call: {label: string, costUsd: number|null, unpricedRounds: number}) => void,
369
376
  * seedFn?: Function, scoutFn?: Function, listingFn?: Function,
370
- * authorFn?: Function, authorOpts?: object}} o
377
+ * authorFn?: Function, authorOpts?: object,
378
+ * signerFix?: Function|any, proposeFn?: Function, compileOpts?: object}} o
371
379
  * @returns {Promise<{ok: boolean, refusal: Refusal|null, verdictType: string|null,
372
- * closeDecl: any, seedRef: string|null, authoring: any, reds: Red[], stop: string|null,
373
- * cost: any, interview: any}>}
380
+ * closeDecl: any, seedRef: string|null, authoring: any, judged: any, reds: Red[],
381
+ * stop: string|null, cost: any, interview: any}>}
374
382
  */
375
383
  export async function authorCloseForJob({
376
384
  answers, repoPath = null, lang, verdictType = null, questions = null,
377
385
  generate, provider = null, seedRef = null, scout = null, listing = null,
378
386
  ceilingUsd = null,
387
+ // THE PROGRESS SEAMS. This composition is the pipeline's long silence — a real
388
+ // survey, a real declaration ladder, and a real toolchain per close stage — and
389
+ // it emitted nothing until it returned, so an operator watching a live run
390
+ // could not tell work from a hang. The library REPORTS and the shell PRINTS
391
+ // (`runJob`'s `emit` → `runPlan`, the same shape): nothing here reads either
392
+ // callback back, and no decision in this file or below it depends on one.
393
+ onPhase = () => {}, onCall = () => {},
379
394
  seedFn = seedAtHead, scoutFn = runAuthorScout, listingFn = buildSeedListing,
380
395
  authorFn = authorClose, authorOpts = {},
396
+ // SOFTGREEN modules 4+5. `signerFix` is the D5 seam: a function (or a value)
397
+ // carrying the SIGNER's corrected artifacts back. Absent means the proposal is
398
+ // signed as proposed, and `signJudgedArtifacts` records which of the two
399
+ // happened rather than leaving a reader to guess.
400
+ signerFix = null, proposeFn = proposeJudgedArtifacts, compileOpts = {},
381
401
  }) {
382
402
  /** @type {any} */
383
403
  const base = {
384
404
  ok: false, refusal: null, verdictType: null, closeDecl: null, seedRef: null,
385
- authoring: null, reds: [], stop: null, cost: null, interview: null,
405
+ authoring: null, judged: null, reds: [], stop: null, cost: null, interview: null,
386
406
  };
387
407
 
388
408
  const interview = runInterview({ answers, verdictType, repoPath, questions });
@@ -425,6 +445,10 @@ export async function authorCloseForJob({
425
445
  // D8 — the seed, READ rather than typed.
426
446
  let seed = seedRef;
427
447
  if (!isNonEmptyString(seed)) {
448
+ // Announced only on the path that actually READS one: a caller who handed a
449
+ // seedRef in never paid for a git call, and a phase line for work nobody did
450
+ // is the blind-instrument class, one line wide.
451
+ onPhase('seed', {});
428
452
  const s = await seedFn(workdir);
429
453
  if (s.stop !== null) {
430
454
  // same git-stderr channel as `prepareSigning`'s, scrubbed at the same
@@ -445,15 +469,46 @@ export async function authorCloseForJob({
445
469
  // form). `null` travels EXPLICITLY rather than being left off — unbounded is a
446
470
  // stated operator choice, and an absent field read as falsy is the same state
447
471
  // arrived at by accident.
448
- const survey = scout ?? await scoutFn({ workdir, provider, ceilingUsd });
449
- const seeds = listing ?? await listingFn({
450
- workdir, seedRef: seed, sourcePaths: survey?.facts?.sourcePaths, testPaths: survey?.facts?.testPaths,
451
- });
472
+ /** @type {any} */
473
+ let survey = scout;
474
+ // `== null` and not `!survey`: this is the `??` these two lines replaced, kept
475
+ // exactly — a caller-supplied survey is used whatever it is, and only an
476
+ // ABSENT one is paid for.
477
+ if (survey == null) {
478
+ // The scout is the pipeline's FIRST paid stage and its longest single
479
+ // silence: up to three ladder attempts, each a real toolable survey over a
480
+ // real tree. `attempts` is the ceiling the scout enforces on itself, so the
481
+ // reader knows how many the line could repeat for.
482
+ onPhase('scout', { attempts: SCOUT_ATTEMPTS });
483
+ survey = await scoutFn({ workdir, provider, ceilingUsd, onCall });
484
+ // …and the survey's own VERDICT, said the moment it lands. `state` is what
485
+ // every downstream refusal keys on (F59: absent is never "no facts needed"),
486
+ // and the fact count is the one number that says whether the ladder came
487
+ // back with anything to author from.
488
+ onPhase('scout-done', { state: survey?.state ?? null, facts: Object.keys(survey?.facts ?? {}).length });
489
+ }
490
+ /** @type {any} */
491
+ let seeds = listing;
492
+ if (seeds == null) {
493
+ onPhase('listing', {});
494
+ seeds = await listingFn({
495
+ workdir, seedRef: seed, sourcePaths: survey?.facts?.sourcePaths, testPaths: survey?.facts?.testPaths,
496
+ });
497
+ // the count is only knowable AFTER, so it rides the done line rather than
498
+ // being guessed onto the start one
499
+ onPhase('listing-done', { files: seeds?.files?.length ?? null, stop: seeds?.stop ?? null });
500
+ }
452
501
 
502
+ // The declaration ladder. The REVISION CAP is deliberately not quoted here:
503
+ // the tighten-only clamp lives in `authorClose` (and `authorOpts` may lower
504
+ // it), so a number spelled at this call site could disagree with the one the
505
+ // loop obeys — `author-call` carries the enforced cap instead, from where it
506
+ // is computed.
507
+ onPhase('author', {});
453
508
  const authored = await authorFn({
454
509
  workdir, seedRef: seed, lang, verdictType: picked,
455
510
  answers: interview.answers, questions: questions ?? questionsFor(picked),
456
- scout: survey, listing: seeds, generate, ceilingUsd, ...authorOpts,
511
+ scout: survey, listing: seeds, generate, ceilingUsd, onPhase, onCall, ...authorOpts,
457
512
  });
458
513
 
459
514
  if (!authored.ok) {
@@ -484,29 +539,111 @@ export async function authorCloseForJob({
484
539
  // declaration above it), and stated here rather than re-derived. The remaining
485
540
  // precondition (D9.3 — the close has work to do) is measured by
486
541
  // `prepareSigning`, which refuses there.
542
+ /** @type {any} */
543
+ let closeDecl = {
544
+ genre: GENRE,
545
+ lang,
546
+ stages: authored.declaration.stages,
547
+ ...(Object.keys(applied).length ? { genreEnv: { ...applied } } : {}),
548
+ // `notes` is the one FREE-TEXT field the model writes straight into the
549
+ // signed spec, and this is that field's persist boundary. Scrubbed here
550
+ // for the same reason `scrubRed` scrubs uniformly a few lines down: every
551
+ // other model-authored string that survives this call rides the ONE
552
+ // redactor, and a signed spec outlives the run that produced it.
553
+ // `redactSecrets` returns a non-matching string byte-identical, so a note
554
+ // carrying nothing hashes exactly as it did — the mask costs the honest
555
+ // case nothing, which is the fail-safe direction.
556
+ ...(authored.declaration.notes?.length
557
+ ? { notes: authored.declaration.notes.map((/** @type {any} */ n) => (typeof n === 'string' ? redactSecrets(n) : n)) }
558
+ : {}),
559
+ };
560
+
561
+ // ── THE TWO SIGNED ARTIFACTS (softgreen module 4, wired here by module 5) ──
562
+ //
563
+ // A close that judges needs a rubric card and a calibration set, and until now
564
+ // nothing in the pipeline called the compile — module 4 shipped the machinery
565
+ // and said so. This is the call: Q6 and Q7 go to the compiler VERBATIM, the
566
+ // proposal is SHOWN (`onPhase`, and the driver prints it), the SIGNER's fix is
567
+ // what is stored, and both land inside the spec where the hash covers them.
568
+ //
569
+ // KEYED OFF THE DECLARATION, never off the picked class: a soft-green close
570
+ // that composed no judged stage needs no card, and a judged stage is a judged
571
+ // stage whatever the radio said. One judged stage is the composition law
572
+ // (AT_MOST_ONCE_KINDS), so the fold's own throw is unreachable here — it stays
573
+ // as the caller-error guard it is.
574
+ /** @type {any} */
575
+ let judged = null;
576
+ if ((closeDecl.stages ?? []).some((/** @type {any} */ s) => isObj(s) && s.kind === JUDGED_FLOOR_KIND)) {
577
+ // ONE CEILING, ALL THREE PAID SEAMS. The declaration loop's own calls are
578
+ // ABSORBED before the compile is asked for, so the ceiling folds in prior
579
+ // spend and re-invoking a seam cannot silently widen it. `absorb` deliberately
580
+ // does not re-fire `onCall`, so nothing is reported twice.
581
+ const book = makeCostBook({ ceilingUsd, onCall });
582
+ book.absorb(authored.cost?.calls ?? []);
583
+ onPhase('rubric', { size: CALIBRATION_SIZE });
584
+ const proposed = await proposeFn({
585
+ answers: interview.answers,
586
+ questions: questions ?? questionsFor(picked),
587
+ generate,
588
+ book,
589
+ ...compileOpts,
590
+ });
591
+ onPhase('rubric-done', { ok: proposed.ok, stop: proposed.stop, attempts: proposed.attempts });
592
+ if (!proposed.ok) {
593
+ return {
594
+ ...base,
595
+ verdictType: picked, seedRef: seed, authoring: authored, judged: { proposal: proposed, signed: null },
596
+ reds: proposed.reds, stop: proposed.stop, cost: book.report(), interview,
597
+ };
598
+ }
599
+
600
+ // THE D5 MOMENT. `signerFix` is the seam a UI (or an operator's own editor)
601
+ // hands the fixed artifacts back through; ABSENT is a legal answer and it is
602
+ // recorded as one — `source: 'proposal'` says *nobody changed this*, which is
603
+ // a different fact from *a person read it and changed nothing*.
604
+ const fix = typeof signerFix === 'function' ? await signerFix(proposed.proposal) : signerFix;
605
+ const signed = signJudgedArtifacts({ proposal: proposed.proposal, fix });
606
+ judged = { proposal: proposed, signed, scrubbed: signed.scrubbed, source: signed.source };
607
+ if (!signed.ok) {
608
+ return {
609
+ ...base,
610
+ verdictType: picked, seedRef: seed, authoring: authored, judged,
611
+ reds: signed.reds, stop: 'rubric-invalid', cost: book.report(), interview,
612
+ };
613
+ }
614
+ // THE SCRUB, ANNOUNCED. A signer signs a hash over the STORED bytes, so bytes
615
+ // the ONE inventory masked on the way in are said out loud rather than left
616
+ // for them to notice — by PATH only, because echoing the matched string would
617
+ // be the same leak one hop on.
618
+ if (signed.scrubbed.length) onPhase('rubric-scrubbed', { paths: signed.scrubbed.map((s) => s.path) });
619
+ // `cases` is an array on every ok path — `validateCalibrationSet` reds a
620
+ // non-array before anything can be `ok` — and the narrowing is stated rather
621
+ // than defaulted: `?? []` here would fold an EMPTY signed set into a close,
622
+ // which is the one direction that must never be reachable.
623
+ closeDecl = foldJudgedArtifacts(closeDecl, { card: signed.card, cases: /** @type {any[]} */ (signed.cases) });
624
+ return {
625
+ ok: true,
626
+ refusal: null,
627
+ verdictType: picked,
628
+ closeDecl,
629
+ seedRef: seed,
630
+ authoring: authored,
631
+ judged,
632
+ reds: authored.reds,
633
+ stop: authored.stop,
634
+ cost: book.report(),
635
+ interview,
636
+ };
637
+ }
638
+
487
639
  return {
488
640
  ok: true,
489
641
  refusal: null,
490
642
  verdictType: picked,
491
- closeDecl: {
492
- genre: GENRE,
493
- lang,
494
- stages: authored.declaration.stages,
495
- ...(Object.keys(applied).length ? { genreEnv: { ...applied } } : {}),
496
- // `notes` is the one FREE-TEXT field the model writes straight into the
497
- // signed spec, and this is that field's persist boundary. Scrubbed here
498
- // for the same reason `scrubRed` scrubs uniformly a few lines down: every
499
- // other model-authored string that survives this call rides the ONE
500
- // redactor, and a signed spec outlives the run that produced it.
501
- // `redactSecrets` returns a non-matching string byte-identical, so a note
502
- // carrying nothing hashes exactly as it did — the mask costs the honest
503
- // case nothing, which is the fail-safe direction.
504
- ...(authored.declaration.notes?.length
505
- ? { notes: authored.declaration.notes.map((/** @type {any} */ n) => (typeof n === 'string' ? redactSecrets(n) : n)) }
506
- : {}),
507
- },
643
+ closeDecl,
508
644
  seedRef: seed,
509
645
  authoring: authored,
646
+ judged,
510
647
  reds: authored.reds,
511
648
  stop: authored.stop,
512
649
  cost: authored.cost,
@@ -587,6 +724,166 @@ const scrubRed = (r) => /** @type {any} */ (Object.fromEntries(
587
724
  Object.entries(r).map(([k, v]) => [k, typeof v === 'string' ? redactSecrets(v) : v]),
588
725
  ));
589
726
 
727
+ /**
728
+ * GATE 4 — the calibration gate, and the only gate here that spends money.
729
+ *
730
+ * It answers ONE question: has this close's own ruler been shown to grade the
731
+ * signer's own ten correctly, itemized, and to resist all five attack styles?
732
+ * Anything else it learns — a missing set, an unwired judge, a dead call — is
733
+ * reported under its own name and refuses, because every one of them leaves the
734
+ * question unanswered and an unanswered calibration is not a passed one.
735
+ *
736
+ * THE CASUALTY SPLIT is the same one the judged stage runs (F45, contract (a)): a
737
+ * provider failure, an unpriced call or an unparseable emission after the ladder
738
+ * says NOTHING about the set. It refuses the signature — an ungraded ruler is
739
+ * still ungraded — but it refuses under the TRANSPORT's name, so the operator is
740
+ * sent to the socket or the meter and never to their own rubric.
741
+ * AND IT MEETS THE ONE CEILING. This is the run's THIRD paid seam — the largest of
742
+ * them, at up to `CALIBRATION_SIZE` locate calls plus a five-artifact battery, each
743
+ * under its own retry ladder — and it used to run with no bound at all while the
744
+ * scout and the declaration loop were both stopped by the operator's own number.
745
+ * That is the advertised budget and the enforced budget being two different numbers.
746
+ * The book is built HERE and PRIOR SPEND IS ABSORBED into it, so the ceiling folds in
747
+ * what the earlier seams already spent and re-invoking a seam cannot silently widen
748
+ * it; the gate reads it between calls and stops as a refusal that names money.
749
+ * @param {{spec: any, judgedStages: any[], judgeLoop: Function|null,
750
+ * onJudgeCost: ((c: any) => void)|null, calibrateFn: Function,
751
+ * ceilingUsd: number|null, priorCalls: any[]}} o
752
+ * @returns {Promise<{ok: boolean, record: any, reds: Red[], refusal: Refusal|null}>}
753
+ */
754
+ async function calibrationGate({ spec, judgedStages, judgeLoop, onJudgeCost, calibrateFn, ceilingUsd, priorCalls }) {
755
+ const cases = spec.closeDecl?.calibration?.cases ?? null;
756
+ // ONE card, and it is the SIGNED one — the card the calibration is graded
757
+ // against must be the card the close will RUN, or the gate certifies a ruler
758
+ // nobody is going to use. `validateCloseDecl` has already refused a second
759
+ // judged stage (AT_MOST_ONCE_KINDS), so there is exactly one here.
760
+ const card = judgedStages[0]?.params?.card ?? null;
761
+
762
+ if (cases === null || cases === undefined) {
763
+ const detail = `This close judges with a rubric card, and no calibration set is stored with it. The judged floor `
764
+ + `is only a floor once the whole pipe has graded ${CALIBRATION_SIZE} signed cases correctly — until then the `
765
+ + 'ruler is unmeasured, and an unmeasured ruler cannot be signed.';
766
+ return {
767
+ ok: false,
768
+ record: { ok: false, stop: 'calibration-missing', judgeModel: JUDGE_MODEL, required: CALIBRATION_SIZE },
769
+ reds: [{ code: 'calibration-missing', path: 'closeDecl.calibration.cases', detail }],
770
+ refusal: refuse({
771
+ kind: 'decision-ready',
772
+ verb: null,
773
+ path: 'closeDecl.calibration',
774
+ detail,
775
+ options: [
776
+ `author the calibration set from the interview's own Q7 answer (${CALIBRATION_SIZE} cases, mixed polarity) and re-sign`,
777
+ 'drop the judged stage and describe a job whose done is machine-checkable',
778
+ 'abandon the job',
779
+ ],
780
+ }),
781
+ };
782
+ }
783
+
784
+ // ONE CEILING, ALL THE PAID SEAMS (`authorCloseForJob` says the same words over
785
+ // the compile). The book absorbs what the run has already spent, so what the gate
786
+ // reads is the REMAINING balance and never a fresh allowance; `absorb` deliberately
787
+ // does not re-fire a reporter, so nothing is counted twice downstream.
788
+ const book = makeCostBook({ ceilingUsd });
789
+ book.absorb(Array.isArray(priorCalls) ? priorCalls : []);
790
+ const cal = await calibrateFn({
791
+ cases,
792
+ card,
793
+ judgeLoop,
794
+ // the gate's own calls join the same tally the ceiling is read against — a
795
+ // ceiling that cannot see the spend it bounds is F45's blind detector in a
796
+ // money coat. The caller's reporter fires with the call's whole record, after.
797
+ onCost: (/** @type {any} */ c) => {
798
+ book.absorb([{ label: c?.label ?? 'calibrate', costUsd: c?.costUsd ?? null, unpricedRounds: c?.unpricedRounds ?? 0 }]);
799
+ if (typeof onJudgeCost === 'function') onJudgeCost(c);
800
+ },
801
+ capStop: () => book.capStop(),
802
+ });
803
+
804
+ /** what the signing evidence KEEPS about this gate. The per-case artifacts are
805
+ * deliberately not in it — they are already in the signed spec, and a second
806
+ * copy in the evidence file is a second thing to keep in step. What IS kept is
807
+ * WHICH BYTES were certified (`cardHash`/`casesHash`/`setHash`) and BY WHICH
808
+ * MODEL (`judgeModel`), which is what makes a judge-model bump detectable at
809
+ * all. */
810
+ const record = {
811
+ ok: cal.ok,
812
+ stop: cal.stop,
813
+ judgeModel: cal.judgeModel,
814
+ cardHash: cal.cardHash,
815
+ casesHash: cal.casesHash,
816
+ setHash: cal.setHash,
817
+ graded: cal.graded,
818
+ failures: cal.failures,
819
+ injection: cal.injection,
820
+ casualty: cal.casualty,
821
+ costUsd: cal.costUsd,
822
+ knownUsd: cal.knownUsd,
823
+ spendComplete: cal.spendComplete,
824
+ calls: cal.calls?.length ?? 0,
825
+ };
826
+ if (cal.ok) return { ok: true, record, reds: [], refusal: null };
827
+
828
+ const reds = cal.reds.map(scrubRed);
829
+ const casualty = cal.casualty !== null && cal.casualty !== undefined;
830
+ /** the OPERATOR's own governance stop, and it is neither a casualty nor a reading
831
+ * of the close: the ceiling ran out mid-question, and what was graded before it
832
+ * did says nothing either way about the rest of the set. */
833
+ const budget = cal.stop === 'cap-halt' || cal.stop === 'pricing-red';
834
+ const detail = budget
835
+ ? (cal.stop === 'cap-halt'
836
+ ? `The calibration gate ran out of CEILING before it could finish: ${cal.graded.length} of `
837
+ + `${Array.isArray(cases) ? cases.length : 0} case(s) were graded and the next call was never made. A `
838
+ + 'part-graded set is an ungraded ruler, so nothing is signable — and nothing here says the ruler is wrong.'
839
+ : 'The calibration gate stopped on an UNPRICED call: with the run\'s total unknown the ceiling cannot be read '
840
+ + 'against it, and unpriced is never free (F6). Nothing here says anything about the set.')
841
+ : casualty
842
+ ? `The calibration gate could not be RUN: the judge produced no usable facts for ${cal.casualty.kind} `
843
+ + `"${cal.casualty.at}" [${cal.casualty.axis}]. A broken judge is a casualty and never a verdict, so this says `
844
+ + 'nothing about the set — but an ungraded ruler is still an ungraded ruler, and it cannot be signed.'
845
+ : cal.stop === 'no-judge'
846
+ ? `This close carries a judged stage and no judge seam was wired, so the ${CALIBRATION_SIZE}-case gate never ran. `
847
+ + 'An absent seam is a wiring gap, never a fall-back: a gate that grades nothing would certify every close.'
848
+ : cal.stop === 'invalid-set'
849
+ ? 'The stored rubric card and calibration set do not compose into something gradeable, so the gate refused '
850
+ + 'before spending anything.'
851
+ : `The whole pipe did not grade this close's own calibration set correctly: `
852
+ + `${cal.failures.length} of ${Array.isArray(cases) ? cases.length : 0} case(s) graded wrong`
853
+ + `${cal.injection.leaks.length ? ` and ${cal.injection.leaks.length} injection style(s) leaked` : ''}. `
854
+ + `The floor is ${CALIBRATION_SIZE}/${CALIBRATION_SIZE} with itemized reds and all styles resisting `
855
+ + '(hamr, 2026-08-18) — the fix for a miss is a new card line or a corrected case, and a re-sign.';
856
+
857
+ return {
858
+ ok: false,
859
+ record,
860
+ reds,
861
+ refusal: refuse({
862
+ kind: 'decision-ready',
863
+ verb: null,
864
+ path: 'closeDecl.calibration',
865
+ detail,
866
+ options: budget
867
+ ? [
868
+ 'raise the authoring ceiling and re-run the gate — the $0 gates above are already passed',
869
+ 'run the gate unbounded (omit the ceiling) — that is a stated operator choice, never a default',
870
+ 'abandon the job',
871
+ ]
872
+ : casualty || cal.stop === 'no-judge'
873
+ ? [
874
+ 'wire a judge provider pinned to the judged tier and re-run the gate',
875
+ 'wait out the provider and re-run — nothing about the close was judged here',
876
+ 'abandon the job',
877
+ ]
878
+ : [
879
+ 'correct the card line or the case the pipe graded differently, and re-sign',
880
+ 'read the itemized rows above: a case the pipe reds for the WRONG reason is the card talking, not the judge',
881
+ 'abandon the job',
882
+ ],
883
+ }),
884
+ };
885
+ }
886
+
590
887
  /**
591
888
  * D9, in code: *nothing JUDGES the close; three mechanical gates plus a
592
889
  * signature.* No LLM validates another LLM's close, here or anywhere.
@@ -600,6 +897,24 @@ const scrubRed = (r) => /** @type {any} */ (Object.fromEntries(
600
897
  * A close with NO work stage red at seed has nothing to do and is refused
601
898
  * decision-ready — gate-2 round 2's arm B died exactly there, on a mypy that
602
899
  * scanned nothing and therefore read clean.
900
+ * 4. THE CALIBRATION GATE (softgreen module 5) — for a close carrying a
901
+ * `judged-floor` stage ONLY. The whole judged pipe runs over the signed ten
902
+ * and the five arbiter-owned injection artifacts, and anything short of
903
+ * 10/10 with itemized reds and 5/5 resisted is an unsignable close. It is
904
+ * the ONE gate here that spends money, so it runs LAST — every free refusal
905
+ * has already had its chance, and nobody pays to discover a broken
906
+ * declaration.
907
+ *
908
+ * D9.3 AND THE JUDGED-ONLY CLOSE (2026-08-18 second addendum, ruling 3; hamr:
909
+ * *"fix it now, we are delivering softgreen, isn't that the whole point?"*). Gate
910
+ * 3 protects against a close that cannot fail — one that scans nothing and reads
911
+ * green at seed against a true red. A judged stage SKIPS the seed read by ruling
912
+ * 8, so a close whose only work stage is judged has no seed red to show and used
913
+ * to be unsignable: the one job shape softgreen exists for. For that shape, and
914
+ * ONLY that shape, a PASSED calibration gate plays the seed-red role — it is that
915
+ * close's own proof of failability, held to the same all-or-nothing bar. A close
916
+ * with a mechanical work stage is UNCHANGED: it still needs a seed red, whatever
917
+ * its calibration says.
603
918
  *
604
919
  * Gates 2 and 3 share ONE execution, and that is stated rather than hidden: at
605
920
  * job creation the tree IS the seed, so "does every stage run against the real
@@ -612,18 +927,32 @@ const scrubRed = (r) => /** @type {any} */ (Object.fromEntries(
612
927
  * comes back is the resolved spec, its hash, and the evidence to sign against.
613
928
  *
614
929
  * @param {{spec: any, workdir: string, seedRef?: string|null, shellCapUsd?: number,
615
- * timeoutMs?: number, seedFn?: Function, listingFn?: Function, seedReadFn?: Function}} o
930
+ * timeoutMs?: number, seedFn?: Function, listingFn?: Function, seedReadFn?: Function,
931
+ * judgeLoop?: Function|null, onJudgeCost?: ((c: any) => void)|null, calibrateFn?: Function,
932
+ * ceilingUsd?: number|null, priorCalls?: any[]}} o
616
933
  * @returns {Promise<{ok: boolean, specHash: string|null, seedRef: string|null,
617
934
  * gates: any, work: any[], guards: any[], stops: any[], reds: Red[], refusal: Refusal|null}>}
618
935
  */
619
936
  export async function prepareSigning({
620
937
  spec, workdir, seedRef = null, shellCapUsd, timeoutMs,
621
938
  seedFn = seedAtHead, listingFn = seedListing, seedReadFn = runSeedRead,
939
+ // THE PAID SEAM, and it is the operator's to wire — this module owns no
940
+ // provider and picks none (the same contract `authorCloseForJob` keeps for the
941
+ // drafting model). Absent is never a fall-back: a judged close whose gate could
942
+ // not run is refused, not waved through.
943
+ judgeLoop = null, onJudgeCost = null, calibrateFn = runCalibration,
944
+ // THE OPERATOR'S CEILING, and the spend it has already met. `null`/absent is
945
+ // UNBOUNDED and is a stated choice, never a default invented here (the same
946
+ // contract `authorCloseForJob` keeps). `priorCalls` is what the earlier paid
947
+ // seams of this run already cost — a `makeCostBook().report().calls` list — and
948
+ // it is ABSORBED so the gate reads the remaining balance rather than opening a
949
+ // fresh allowance under the same number.
950
+ ceilingUsd = null, priorCalls = [],
622
951
  }) {
623
952
  /** @type {any} */
624
953
  const base = {
625
954
  ok: false, specHash: null, seedRef: null, work: [], guards: [], stops: [], reds: [], refusal: null,
626
- gates: { declaration: null, precheck: null, seedVerdict: null },
955
+ gates: { declaration: null, precheck: null, seedVerdict: null, calibration: null },
627
956
  };
628
957
 
629
958
  // ── gate 1a: the spec itself. A declared close that is not on a plan-shape
@@ -759,12 +1088,41 @@ export async function prepareSigning({
759
1088
  workRed: workRed.map((r) => r.stage),
760
1089
  };
761
1090
 
1091
+ // ── gate 4: THE CALIBRATION GATE (softgreen module 5). The ONLY paid gate, and
1092
+ // it runs LAST for exactly that reason: every $0 refusal above has already had
1093
+ // its chance, so a broken declaration or a stage that cannot run is found for
1094
+ // free. MANDATORY for any close carrying a judged-floor stage — module 4's
1095
+ // validator checks a stored set for LEGALITY only and deliberately allows a
1096
+ // judged stage with no set at all, because making it mandatory is a signing
1097
+ // decision and this is the signing gate.
1098
+ const judgedStages = (spec.closeDecl.stages ?? []).filter((/** @type {any} */ s) => isObj(s) && s.kind === JUDGED_FLOOR_KIND);
1099
+ if (judgedStages.length) {
1100
+ const cal = await calibrationGate({ spec, judgedStages, judgeLoop, onJudgeCost, calibrateFn, ceilingUsd, priorCalls });
1101
+ base.gates.calibration = cal.record;
1102
+ if (!cal.ok) return { ...base, work, guards, reds: cal.reds, refusal: cal.refusal };
1103
+ }
1104
+
762
1105
  // D9.3 — nothing red at seed means nothing to do. Round 2's arm B read GREEN
763
1106
  // with value 0 because mypy died on an unrelated broken fixture and scanned
764
1107
  // nothing: an instrument scanning nothing reads clean, which is F6's shape at
765
1108
  // the close-authoring layer, and this gate is what refuses it BEFORE a
766
1109
  // signature rather than after a paid run.
767
- if (!workRed.length) {
1110
+ //
1111
+ // THE JUDGED-ONLY ROUTE (ruling 3). A close whose work stages are ALL
1112
+ // seed-exempt has no seed verdict to be red — not because it measures nothing,
1113
+ // but because ruling 8 exempts a judged stage from being measured at seed. Its
1114
+ // proof of failability is the calibration gate, which has just run and passed
1115
+ // (a failed one returned above), and which contains signed cases the pipe must
1116
+ // RED as well as ones it must pass — the polarity law makes "this close can
1117
+ // fail" a thing that was demonstrated rather than assumed. A close with any
1118
+ // mechanical work stage takes the original path unchanged.
1119
+ const mechanicalWork = work.filter((r) => !SEED_EXEMPT_KINDS.includes(r.kind));
1120
+ const judgedOnly = judgedStages.length > 0 && mechanicalWork.length === 0;
1121
+ if (judgedOnly && base.gates.calibration?.ok) {
1122
+ // said out loud in the record: this close passed gate 3 on a DIFFERENT proof,
1123
+ // and a reader must never have to infer which one from an empty workRed list
1124
+ base.gates.seedVerdict.satisfiedBy = 'calibration';
1125
+ } else if (!workRed.length) {
768
1126
  return {
769
1127
  ...base,
770
1128
  work,
@@ -349,6 +349,7 @@ const defaultLoop = (/** @type {any} */ { system, policy, onLlmResult, provider
349
349
  * @param {{workdir: string, provider?: any,
350
350
  * rounds?: number, minBytes?: number, blobMax?: number, maxTokens?: number, ctx?: boolean,
351
351
  * attempts?: number, ceilingUsd?: number|null,
352
+ * onCall?: (call: {label: string, costUsd: number|null, unpricedRounds: number}) => void,
352
353
  * createLoop?: (o: {system: string, policy: any, onLlmResult: any, provider: any}) => any,
353
354
  * createSurveyor?: (o: {workdir: string, granted: readonly string[], ctx: boolean}) => Promise<any>}} o
354
355
  * @returns {Promise<Survey & {raw: string, raws: ReturnType<typeof scrubRaw>[],
@@ -364,6 +365,7 @@ export async function runAuthorScout({
364
365
  ctx = true,
365
366
  attempts = SCOUT_ATTEMPTS,
366
367
  ceilingUsd = null,
368
+ onCall = () => {},
367
369
  createLoop = defaultLoop,
368
370
  createSurveyor = defaultSurveyor,
369
371
  }) {
@@ -384,7 +386,17 @@ export async function runAuthorScout({
384
386
  * @param {string} label @param {number} attempt @param {any} r @returns {number}
385
387
  */
386
388
  const record = (label, attempt, r) => {
387
- calls.push({ label, ...priceOf(r) });
389
+ const call = { label, ...priceOf(r) };
390
+ calls.push(call);
391
+ // …and the SHELL is told, at the same instant and off the same entry. A run
392
+ // killed from outside used to lose 100% of its spend record (F6/F12: a
393
+ // halted attempt's spend was invisible by 300×) — the totals only ever
394
+ // existed in this array, which dies with the process. Fired from inside the
395
+ // one recorder rather than at the call sites, so a paid call cannot be
396
+ // metered without the report going out: exactly the pairing invariant the
397
+ // raw already rides. It REPORTS and decides nothing; the ceiling still binds
398
+ // through `capStop` between calls, untouched.
399
+ onCall({ ...call });
388
400
  return raws.push(scrubRaw({ label, attempt, text: r?.text })) - 1;
389
401
  };
390
402
  // TIGHTEN-ONLY: an operator may lower the ceiling, never raise it. A garbage