bareloop 0.9.0 → 0.11.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/authoring.js CHANGED
@@ -94,9 +94,11 @@ import { isObj, isNonEmptyString, hasNestedQuantifier, globToPrefix } from './va
94
94
 
95
95
  /** the whole menu, in ascending order of what it takes to render the verdict */
96
96
  export const VERDICT_CLASSES = Object.freeze(['green', 'soft-green', 'hitl']);
97
- /** declared-but-locked: selecting one is COUNTED demand, never an admission */
98
- export const LOCKED_CLASSES = Object.freeze(['soft-green', 'hitl']);
99
- /** the classes v1 actually builds — exactly one */
97
+ /** declared-but-locked: selecting one is COUNTED demand, never an admission.
98
+ * `hitl` LEFT this list at N4 slice 1 (see `src/job.js`'s twin) — the class is
99
+ * built, and its battery is below. `soft-green` waits for the judged floor. */
100
+ export const LOCKED_CLASSES = Object.freeze(['soft-green']);
101
+ /** the classes v1 actually builds */
100
102
  export const LIVE_CLASSES = Object.freeze(VERDICT_CLASSES.filter((c) => !LOCKED_CLASSES.includes(c)));
101
103
 
102
104
  /** the hierarchy as a comparable number. `green` is the FLOOR: a mechanical
@@ -237,11 +239,22 @@ export const KIND_CATALOGUE = Object.freeze({
237
239
  shape: 'LOCKED — not available in v1',
238
240
  asserts: 'a judged score must clear a floor. Declaring it is recorded as demand; it will not run.',
239
241
  }),
242
+ // LIVE at N4 slice 1, and the ONLY kind that does not measure anything: it
243
+ // does not RUN, it PAUSES the run and hands the tree to a person (ruling 1).
244
+ // `ask` is the whole parameter surface — no cmd, no timeoutMs, no env — so a
245
+ // human stage cannot spawn, cannot be env-capable, and cannot be given a
246
+ // second job. `offer` is never one of its fields either: ruling 5's law is a
247
+ // LAW, checked by the validator, not a knob the composer sets.
240
248
  'human-confirms': Object.freeze({
241
249
  verdictClass: 'hitl',
242
- required: Object.freeze([]), optional: Object.freeze([]), pathParams: Object.freeze([]), locked: true,
243
- shape: 'LOCKED — not available in v1',
244
- asserts: 'a person renders the verdict. Declaring it is recorded as demand; it will not run.',
250
+ required: Object.freeze(['ask']),
251
+ optional: Object.freeze([]),
252
+ pathParams: Object.freeze([]),
253
+ shape: 'ask: string',
254
+ asserts: 'a PERSON renders this verdict, at the end of the run, and nothing about it is measured. Write `ask` as '
255
+ + 'the plain question they answer while looking at the finished result. It is always the LAST stage, it is '
256
+ + 'never offered to the agent as an in-run check, and every part of the job a command can decide belongs in a '
257
+ + 'mechanical stage before it.',
245
258
  }),
246
259
  });
247
260
 
@@ -417,6 +430,86 @@ export const TYPES_GENRE = Object.freeze({
417
430
  /** no import-resolution hazard on this runner — an EMPTY list, declared,
418
431
  * never an absent field that would read as "not looked into" (F59) */
419
432
  env: Object.freeze([]),
433
+ instruments: Object.freeze([
434
+ Object.freeze({
435
+ id: 'tsc-error-line',
436
+ what: 'one type error, as the TypeScript checker prints it',
437
+ lineMatch: 'error TS\\d+',
438
+ capture: null,
439
+ // REAL, from the captured `npm run typecheck -- --strict` of the pulselog
440
+ // patient (2026-08-09): 67 error lines, every one in this shape.
441
+ example: 'src/backup.js(21,19): error TS7006: Parameter \'now\' implicitly has an \'any\' type.',
442
+ why: 'tsc prints a DIFFERENT shape on a terminal (`src/backup.js:21:19 - error TS7006:`) than through a '
443
+ + 'pipe, and a close only ever reads the piped one. Run msmbpjk6 composed the terminal shape: it matched '
444
+ + '0 of 67 real error lines while the tool exited 2, so the stage read a crashed instrument. This pattern '
445
+ + 'is anchored on the CODE, not on the path spelling, so it reads both shapes and cannot lose that flip.',
446
+ }),
447
+ // ── node --test, the SAME pipe-vs-terminal trap, MEASURED ──────────────
448
+ //
449
+ // PROVENANCE, two independent halves.
450
+ //
451
+ // (1) REAL CAPTURED OUTPUT. The mailproof job-#2 battery's own spines carry
452
+ // whole `npm test` → `node --test` captures inside their `close-precheck`
453
+ // gap: `bareloop-patients/mailproof-job2-bareloop/battery-P1-mrm7gk25.jsonl`
454
+ // (`# tests 317` / `# pass 312` / `# fail 5`, five `not ok` lines) and
455
+ // `battery-P2-mrm7gk25.jsonl` (`# pass 316` / `# fail 1`). Nothing there was
456
+ // written to match a pattern — it is what the runner printed down the close's
457
+ // pipe, months before this table existed.
458
+ //
459
+ // (2) A SHIPPED, LIVE-FIRED SPELLING. `jobs/pulselog-author-types.json`
460
+ // already grades `^# pass (\d+)$` and `^# fail (\d+)$` (stages `tests-kept`
461
+ // and `test-suite-zero-failures`), and run msoc6t8v graded green through it.
462
+ // The table is not minting a new ruler; it is HANDING OVER the one the tree
463
+ // already grades with, the way the tsc term does.
464
+ //
465
+ // WHY IT IS THE TSC TRAP AGAIN, measured on this machine (node v22.22.2,
466
+ // one test file, identical args, 2026-08-13): through a PIPE the runner
467
+ // prints TAP — `TAP version 13`, `# pass 1`, `not ok 2 - b`; on a real pty it
468
+ // prints the spec reporter — `ℹ pass 1`, no `#`, no TAP at all. A close only
469
+ // ever reads the piped half, and a pattern composed against the pretty half
470
+ // matches nothing while the suite runs fine. Exactly what cost run msmbpjk6
471
+ // its authoring pass on tsc, waiting behind a second tool.
472
+ Object.freeze({
473
+ id: 'node-test-total-count',
474
+ what: 'how many tests the runner EXECUTED, off the TAP summary',
475
+ lineMatch: '^# tests (\\d+)$',
476
+ capture: 1,
477
+ example: '# tests 317',
478
+ why: 'the EXECUTED count is the honest denominator: a passed-count floor is satisfiable by a suite that '
479
+ + 'stopped running, so a stage asking "is the suite still whole?" counts this, not passes. Anchored '
480
+ + 'both ends because the runner prefixes ordinary chatter with the same `# ` (`# Subtest: <name>`), and '
481
+ + 'an unanchored `# tests` would count test NAMES.',
482
+ }),
483
+ Object.freeze({
484
+ id: 'node-test-pass-count',
485
+ what: 'how many tests passed, off the TAP summary',
486
+ lineMatch: '^# pass (\\d+)$',
487
+ capture: 1,
488
+ example: '# pass 316',
489
+ why: 'the spelling `jobs/pulselog-author-types.json` already grades with, green through run msoc6t8v. '
490
+ + 'The runner prints this line on a green run too, so a zero here is a REPORTED zero.',
491
+ }),
492
+ Object.freeze({
493
+ id: 'node-test-fail-count',
494
+ what: 'how many tests failed, off the TAP summary',
495
+ lineMatch: '^# fail (\\d+)$',
496
+ capture: 1,
497
+ example: '# fail 1',
498
+ why: 'this runner prints every counter on every run — a fully green suite still prints `# fail 0` '
499
+ + '(measured), so reading the figure cannot confuse "no failures" with "never reported". Its pytest '
500
+ + 'counterpart has no such guarantee, which is why python has no fail-COUNT term.',
501
+ }),
502
+ Object.freeze({
503
+ id: 'node-test-failed-line',
504
+ what: 'one failing test, as the TAP reporter names it',
505
+ lineMatch: '^not ok \\d+ ',
506
+ capture: null,
507
+ example: 'not ok 7 - edit-renotify: a non-participant edit, and edits on a pending event, ping no one',
508
+ why: 'one line per failing test, so it TALLIES and no match is a genuine counted zero. The trailing '
509
+ + 'space and the `^` both carry weight: every passing test prints `ok 7 - …`, and without the anchor '
510
+ + 'each of those would count as a failure.',
511
+ }),
512
+ ]),
420
513
  }),
421
514
  python: Object.freeze({
422
515
  suppressions: Object.freeze([
@@ -437,6 +530,58 @@ export const TYPES_GENRE = Object.freeze({
437
530
  + 'found by probe). It moves no seed number, so no seed-verdict read can see its absence.',
438
531
  }),
439
532
  ]),
533
+ instruments: Object.freeze([
534
+ Object.freeze({
535
+ id: 'mypy-error-line',
536
+ what: 'one type error, as mypy prints it',
537
+ lineMatch: ' error: ',
538
+ capture: null,
539
+ // REAL, from a live aurora run's own spine
540
+ // (bareloop-patients/aurora-u-bareloop/u-ms2c0ls7.jsonl).
541
+ example: 'packages/spawner/src/aurora_spawner/timeout_policy.py:89: error: Statement is unreachable [unreachable]',
542
+ why: 'the operator\'s own hand-written close reads mypy this way (scripts/u-spawner-close.mjs:133), minted '
543
+ + 'by paid live runs. It is anchored on the SEVERITY word, not on the path spelling, so a relative or '
544
+ + 'absolute path, a package prefix or a config-driven root all read the same.',
545
+ }),
546
+ // ── pytest, from the aurora battery's own captured runs ────────────────
547
+ //
548
+ // PROVENANCE: six `close-precheck` gaps in
549
+ // `bareloop-patients/aurora-soar-bareloop/battery-A{1..4}-*.jsonl` carry the
550
+ // whole captured pytest run of the aurora patient (pytest 9.1.1, python
551
+ // 3.14.6, the patient's own `pytest.ini` addopts, which include `-ra`) —
552
+ // 2,690-odd tests, the `short test summary info` section and the final counts
553
+ // line. Real, uncrafted, paid for.
554
+ //
555
+ // WHAT IS NOT ANCHORED, and why. Every archived counts line arrives padded
556
+ // with `=` on both sides, and the padding WIDTH is terminal-derived: the
557
+ // archived long line gets exactly one `=` per side, a short local run gets
558
+ // twenty-five (measured, pytest 9.0.2, 2026-08-13). Under `-q` the padding is
559
+ // absent entirely. So the term anchors on the WORD and its number, never on
560
+ // the decoration — the same discipline as anchoring tsc on the code rather
561
+ // than the path.
562
+ Object.freeze({
563
+ id: 'pytest-pass-count',
564
+ what: 'how many tests passed, off pytest\'s final counts line',
565
+ lineMatch: '(\\d+) passed',
566
+ capture: 1,
567
+ example: '= 1 failed, 2690 passed, 3 skipped, 18 deselected, 1775 warnings in 326.12s (0:05:26) =',
568
+ why: 'the ONE segment of this line pytest prints whether the run was green or red, so it is the only '
569
+ + 'count on it that can be read as a figure without risking "never reported". The other segments '
570
+ + '(failed, skipped, deselected) are omitted when their count is zero — a green run prints just '
571
+ + '`1 passed in 0.04s` (measured) — so a term reading THOSE would go silent exactly when the news is '
572
+ + 'good, and a silent figure term is a broken instrument, not a zero.',
573
+ }),
574
+ Object.freeze({
575
+ id: 'pytest-failed-line',
576
+ what: 'one failing test, as pytest\'s short summary names it',
577
+ lineMatch: '^FAILED ',
578
+ capture: null,
579
+ example: 'FAILED packages/soar/tests/test_agent_registry_deprecation.py::TestAgentRegistryDeprecation::test_registry_and_discovery_equivalent_results',
580
+ why: 'this is how you count pytest FAILURES: exactly one line per failing test (measured under both `-ra` '
581
+ + 'and `-q`), so it TALLIES and no match at all is a legitimate counted zero rather than an unreported '
582
+ + 'number. It needs pytest\'s short summary section, which the patient\'s own config must not suppress.',
583
+ }),
584
+ ]),
440
585
  }),
441
586
  }),
442
587
  });
@@ -481,30 +626,40 @@ function language(lang) {
481
626
  * than hand back nothing if anything ever does.
482
627
  * @type {Record<string, {locked: boolean, guards: readonly any[]|null}>}
483
628
  */
484
- export const CLASS_BATTERIES = Object.freeze({
485
- green: Object.freeze({
486
- locked: false,
487
- guards: Object.freeze([
488
- Object.freeze({
489
- name: 'changed-from-seed',
490
- kind: 'files-changed',
491
- params: Object.freeze({ requireNonEmpty: true }),
492
- compose: Object.freeze([]),
493
- fill: Object.freeze(['allowPrefixes']),
494
- }),
495
- Object.freeze({
496
- name: 'no-suppressions',
497
- kind: 'pattern-absent-in-diff',
498
- // NO scope: the scan covers every changed file of these extensions. A
499
- // narrowing here is the round-3 arm A defect and is a red, not a taste.
500
- params: Object.freeze({}),
501
- compose: Object.freeze(['patterns', 'extensions']),
502
- fill: Object.freeze([]),
503
- }),
504
- ]),
629
+ /** THE MECHANICAL BATTERY — the two guards a machine can always render, named
630
+ * once because TWO classes carry exactly them (OPEN-1's ruling below). It is
631
+ * shared by reference and never mutated: `classGuards` deep-copies before it
632
+ * hands anything back, so one battery cannot be weakened through another class. */
633
+ const MECHANICAL_GUARDS = Object.freeze([
634
+ Object.freeze({
635
+ name: 'changed-from-seed',
636
+ kind: 'files-changed',
637
+ params: Object.freeze({ requireNonEmpty: true }),
638
+ compose: Object.freeze([]),
639
+ fill: Object.freeze(['allowPrefixes']),
640
+ }),
641
+ Object.freeze({
642
+ name: 'no-suppressions',
643
+ kind: 'pattern-absent-in-diff',
644
+ // NO scope: the scan covers every changed file of these extensions. A
645
+ // narrowing here is the round-3 arm A defect and is a red, not a taste.
646
+ params: Object.freeze({}),
647
+ compose: Object.freeze(['patterns', 'extensions']),
648
+ fill: Object.freeze([]),
505
649
  }),
650
+ ]);
651
+
652
+ export const CLASS_BATTERIES = Object.freeze({
653
+ green: Object.freeze({ locked: false, guards: MECHANICAL_GUARDS }),
506
654
  'soft-green': Object.freeze({ locked: true, guards: null }),
507
- hitl: Object.freeze({ locked: true, guards: null }),
655
+ // OPEN-1, RULED (hamr, in-turn, 2026-08-13): **hitl inherits the green
656
+ // mechanical guards and adds nothing a human stage cannot see.** D5 makes the
657
+ // battery mandatory, shown-and-fixed and un-removable, and PRD v1.57 §2 keys it
658
+ // to the CLASS; the mechanical-first composition law makes every hitl close
659
+ // mostly mechanical anyway, so the green battery is the right FLOOR. A guard a
660
+ // person cannot verify by looking would be a guard nobody checks — the one
661
+ // thing D5 exists to make unsayable.
662
+ hitl: Object.freeze({ locked: false, guards: MECHANICAL_GUARDS }),
508
663
  });
509
664
 
510
665
  /**
@@ -599,6 +754,64 @@ export function genreOwnedEnvNames(lang) {
599
754
  return language(lang).env.map((/** @type {any} */ e) => e.name);
600
755
  }
601
756
 
757
+ /**
758
+ * HOW THE GENRE'S OWN TOOLS PRINT — genre property, like the suppression battery
759
+ * and like `MYPYPATH`, and for the same reason: it is a FACT ABOUT AN INSTRUMENT
760
+ * WE ALREADY OWN, not a composition choice. Seven hand-written closes read tsc
761
+ * with the identical `/error TS\d+/` (`scripts/u-pulselog-close.mjs:122`,
762
+ * `u-bareguard-close.mjs:123`, `u-bareagent-close.mjs:123`,
763
+ * `u-baremobile-close.mjs:122`, `u-litectx-close.mjs:140`, `types-close.mjs:190`,
764
+ * `types-check.mjs:60`) and one reads mypy with `' error: '`
765
+ * (`u-spawner-close.mjs:133`) — the same provenance the SUPPRESSIONS table has:
766
+ * operator knowledge minted by paying for live runs.
767
+ *
768
+ * WHY IT BECAME GENRE DATA (run msmbpjk6, 2026-08-09, $0.43). Left to the model,
769
+ * the parser for a known tool is a DRAFT-TIME LOTTERY on a fact nobody needed to
770
+ * guess: that run composed tsc's TTY-only pretty shape
771
+ * (`^\S+\.js:\d+:\d+ - error TS\d+:`), which matched 0 of the 67 real error lines
772
+ * the piped run actually printed, while the tool exited 2. The gates held — the
773
+ * stage read as a crashed instrument and signing was refused, correctly — but the
774
+ * refusal cost a whole authoring run to learn something the tree already knew. An
775
+ * earlier run (mslwbkz7) happened to roll the right shape. A coin-flip on an
776
+ * owned fact is not a capability question.
777
+ *
778
+ * WHAT THIS IS NOT: it does not close the parser. A stage counting something we
779
+ * have NOT measured still writes its own pattern, and that remains a real gap —
780
+ * named, not smoothed. This table is where a measured instrument fact lands, one
781
+ * at a time, with its provenance.
782
+ *
783
+ * THE TEST-COUNT FACTS (2026-08-13) landed under exactly that rule, and closed
784
+ * two of the three gaps the paragraph above used to name by hand: the executed
785
+ * count and the failure line, for both runners, are now measured facts rather
786
+ * than draft-time lotteries. Provenance rides on each entry.
787
+ *
788
+ * WHAT STAYED OUT, deliberately, because no evidence covers it:
789
+ * - a pytest FAIL-COUNT figure. A green pytest run omits the segment entirely
790
+ * (`1 passed in 0.04s`), so a figure term over it reports "never reported" on
791
+ * precisely the runs that went well. Failures are counted with the `^FAILED `
792
+ * tally instead, where no match IS zero.
793
+ * - a pytest EXECUTED count. `collected N items` is printed under `-ra` and NOT
794
+ * under `-q` (measured both ways), so no single spelling survives the modes a
795
+ * patient may configure; a stage needing it still composes its own and says so.
796
+ *
797
+ * Returns a fresh copy per call, for the reason `classGuards` does: a frozen
798
+ * constant handed to a caller that edits it is one shared table away from a
799
+ * silently changed ruler.
800
+ * @param {string} lang
801
+ * @returns {{id: string, what: string, lineMatch: string, capture: number|null, example: string, why: string}[]}
802
+ */
803
+ export function genreInstruments(lang) {
804
+ const instruments = language(lang).instruments ?? [];
805
+ // F59's shape at a table that feeds a RULER: an empty list would read as "this
806
+ // language's tools need no stated format", which is exactly the sentence that
807
+ // put the pretty-format regex into a signed-close candidate.
808
+ if (!instruments.length) {
809
+ throw new Error(`no known-instrument formats for language "${lang}" — a language the genre admits states how its `
810
+ + 'own checker prints, or the model is back to guessing a fact we already own');
811
+ }
812
+ return instruments.map((/** @type {any} */ i) => ({ ...i }));
813
+ }
814
+
602
815
  /**
603
816
  * GROUNDING for a RECORDED genre environment — the provenance half of the
604
817
  * owned-env rule, and the reason the by-value comparison is worth anything.
@@ -735,7 +948,12 @@ function scopeOfJob(declaration, idx, guards) {
735
948
  * stage — applied to the one population that decides whether the law fires at
736
949
  * all.
737
950
  */
738
- const AT_MOST_ONCE_KINDS = Object.freeze(['files-changed']);
951
+ const AT_MOST_ONCE_KINDS = Object.freeze(['files-changed', 'human-confirms']);
952
+
953
+ /** the kinds NO ONE runs as an in-run ruler — ruling 5's law, as data. A
954
+ * declaration that offers one is a red rather than a normalization: the signer
955
+ * must not read one thing and have the runner store another. */
956
+ export const NEVER_OFFERED_KINDS = Object.freeze(['human-confirms', 'judged-floor']);
739
957
 
740
958
  /** @param {any} v */
741
959
  const strArray = (v) => Array.isArray(v) && v.length > 0 && v.every(isNonEmptyString);
@@ -938,13 +1156,43 @@ export function validateDeclaration(declaration, opts = {}) {
938
1156
  if (AT_MOST_ONCE_KINDS.includes(s.kind)) {
939
1157
  const owner = kindOwners.get(s.kind);
940
1158
  if (owner !== undefined) {
941
- red('duplicate-kind', `${at}.kind`, `stage "${label}" is a second ${s.kind} stage — "${owner}" already declares `
942
- + 'which files this job may change. Stages are ANDed, so two of them mean the intersection of two prefix sets, '
943
- + 'which is itself a prefix set and already says the same thing in one stage; what a second one does add is a '
944
- + 'second answer to what this job\'s scope IS', { kind: s.kind, stage: label, twin: owner });
1159
+ red('duplicate-kind', `${at}.kind`, s.kind === 'human-confirms'
1160
+ ? `stage "${label}" is a second ${s.kind} stage — "${owner}" already asks the person. One signer, one judge `
1161
+ + '(ruling 4), asked ONCE at the end: a second door is a second verdict, and the run has no way to say '
1162
+ + 'which of them was the one it stopped for'
1163
+ : `stage "${label}" is a second ${s.kind} stage — "${owner}" already declares `
1164
+ + 'which files this job may change. Stages are ANDed, so two of them mean the intersection of two prefix sets, '
1165
+ + 'which is itself a prefix set and already says the same thing in one stage; what a second one does add is a '
1166
+ + 'second answer to what this job\'s scope IS', { kind: s.kind, stage: label, twin: owner });
945
1167
  } else kindOwners.set(s.kind, label);
946
1168
  }
947
1169
 
1170
+ // ── RULING 5, AS LAW: judged and human stages are `offer: false`, and the
1171
+ // law is checked rather than applied. `checkMenu` filters `offer !== false`,
1172
+ // so a declaration that says nothing is silent about a rule the runner
1173
+ // enforces anyway (`declaredStages` stamps it) — that part is the arbiter's
1174
+ // own bookkeeping. What must never happen is a declaration that says
1175
+ // something ELSE: `offer: true` on a human stage is the composer asking to
1176
+ // use a person as an in-run ruler, and a silent normalization would leave
1177
+ // the signer reading one thing while the runner stored another.
1178
+ if (NEVER_OFFERED_KINDS.includes(s.kind) && Object.hasOwn(s, 'offer') && s.offer !== false) {
1179
+ red('human-stage-offered', `${at}.offer`, `stage "${label}" is a ${s.kind} stage and offers itself as an in-run `
1180
+ + 'check. A person (or a paid judge) is never an in-run ruler (ruling 5): the agent may never use one to '
1181
+ + 'decide a step, and this is refused rather than quietly rewritten to offer: false',
1182
+ { kind: s.kind, stage: label });
1183
+ }
1184
+ // ── THE COMPOSITION LAW's ordering half (2026-08-07: *mechanical first,
1185
+ // judge minimal, human LAST*), enforced where it is cheap. First-red-wins
1186
+ // means anything after the human stage never runs — so a human stage in the
1187
+ // middle silently deletes the mechanical stages behind it AND empties the
1188
+ // evidence package ruling 2 requires the person to be shown.
1189
+ if (s.kind === 'human-confirms' && i !== stages.length - 1) {
1190
+ red('human-stage-not-last', `${at}.kind`, `stage "${label}" asks a person and is not the LAST stage. The close `
1191
+ + 'is first-red-wins, so every stage after this one would never run: the person would be shown an incomplete '
1192
+ + 'evidence package and the mechanical stages behind them would be silently deleted (the composition law: '
1193
+ + 'mechanical first, human last)', { kind: s.kind, stage: label, at: i, last: stages.length - 1 });
1194
+ }
1195
+
948
1196
  const p = s.params;
949
1197
  if (!isObj(p)) { red('missing-field', `${at}.params`, `an object carrying: ${spec.required.join(', ')}`); return; }
950
1198
  for (const k of spec.required) {
@@ -1148,6 +1396,15 @@ function checkKind({ kind, params: p, at, red, scoped, label, populations, envOw
1148
1396
  if (!strArray(p.allowPrefixes)) red('invalid-value', `${at}.params.allowPrefixes`, 'a non-empty array of path prefixes');
1149
1397
  if (p.requireNonEmpty !== true) red('invalid-value', `${at}.params.requireNonEmpty`, 'literally true');
1150
1398
  break;
1399
+ case 'human-confirms':
1400
+ // the whole surface: the question the signer answers. It is shown to a
1401
+ // person verbatim, so an empty one is a bare "approve?" — ruling 2's
1402
+ // named failure mode, refused here rather than rendered.
1403
+ if (!isNonEmptyString(p.ask)) {
1404
+ red('invalid-value', `${at}.params.ask`, 'the plain question the signer answers while looking at the finished '
1405
+ + 'result — a human stage with nothing to ask is the bare "approve?" ruling 2 forbids');
1406
+ }
1407
+ break;
1151
1408
  default:
1152
1409
  red('unknown-kind', `${at}.kind`, `"${kind}" is in the catalogue but has no checker`, { kind });
1153
1410
  }
package/src/authorjob.js CHANGED
@@ -96,7 +96,7 @@ import {
96
96
  GENRE_LANGUAGES, LOCKED_KINDS, TYPES_GENRE, VERDICT_CLASSES, LOCKED_CLASSES, LIVE_CLASSES,
97
97
  } from './authoring.js';
98
98
  import { QUESTION_SETS, questionsFor, requiredAnswersFor, authorClose } from './authorflow.js';
99
- import { runAuthorScout, buildSeedListing } from './authorscout.js';
99
+ import { runAuthorScout, buildSeedListing, SCOUT_ATTEMPTS } from './authorscout.js';
100
100
  import {
101
101
  DECLARED_GAP_PREFIX, DECLARED_GENRES, guardNames, isDeclaredClose, validateCloseDecl,
102
102
  } from './declaredclose.js';
@@ -365,7 +365,9 @@ function composerRefusal(reds) {
365
365
  * @param {{answers: Record<string|number, any>, repoPath?: string|null, lang: string,
366
366
  * verdictType?: string|null,
367
367
  * questions?: Record<string|number, string>|null, generate?: Function, provider?: any,
368
- * seedRef?: string|null, scout?: any, listing?: any,
368
+ * seedRef?: string|null, scout?: any, listing?: any, ceilingUsd?: number|null,
369
+ * onPhase?: (phase: string, data?: any) => void,
370
+ * onCall?: (call: {label: string, costUsd: number|null, unpricedRounds: number}) => void,
369
371
  * seedFn?: Function, scoutFn?: Function, listingFn?: Function,
370
372
  * authorFn?: Function, authorOpts?: object}} o
371
373
  * @returns {Promise<{ok: boolean, refusal: Refusal|null, verdictType: string|null,
@@ -375,6 +377,14 @@ function composerRefusal(reds) {
375
377
  export async function authorCloseForJob({
376
378
  answers, repoPath = null, lang, verdictType = null, questions = null,
377
379
  generate, provider = null, seedRef = null, scout = null, listing = null,
380
+ ceilingUsd = null,
381
+ // THE PROGRESS SEAMS. This composition is the pipeline's long silence — a real
382
+ // survey, a real declaration ladder, and a real toolchain per close stage — and
383
+ // it emitted nothing until it returned, so an operator watching a live run
384
+ // could not tell work from a hang. The library REPORTS and the shell PRINTS
385
+ // (`runJob`'s `emit` → `runPlan`, the same shape): nothing here reads either
386
+ // callback back, and no decision in this file or below it depends on one.
387
+ onPhase = () => {}, onCall = () => {},
378
388
  seedFn = seedAtHead, scoutFn = runAuthorScout, listingFn = buildSeedListing,
379
389
  authorFn = authorClose, authorOpts = {},
380
390
  }) {
@@ -424,6 +434,10 @@ export async function authorCloseForJob({
424
434
  // D8 — the seed, READ rather than typed.
425
435
  let seed = seedRef;
426
436
  if (!isNonEmptyString(seed)) {
437
+ // Announced only on the path that actually READS one: a caller who handed a
438
+ // seedRef in never paid for a git call, and a phase line for work nobody did
439
+ // is the blind-instrument class, one line wide.
440
+ onPhase('seed', {});
427
441
  const s = await seedFn(workdir);
428
442
  if (s.stop !== null) {
429
443
  // same git-stderr channel as `prepareSigning`'s, scrubbed at the same
@@ -437,15 +451,53 @@ export async function authorCloseForJob({
437
451
  // D11 — the scout LOOKS, the author WRITES, neither acts. An ABSENT survey is
438
452
  // never "no special facts are needed" (F59), and `authorClose` refuses on it at
439
453
  // $0, so nothing here has to second-guess the classification.
440
- const survey = scout ?? await scoutFn({ workdir, provider });
441
- const seeds = listing ?? await listingFn({
442
- workdir, seedRef: seed, sourcePaths: survey?.facts?.sourcePaths, testPaths: survey?.facts?.testPaths,
443
- });
454
+ // ONE CEILING, BOTH PAID SEAMS. The survey and the declaration loop are two
455
+ // populations of paid call with two accumulators, and the operator set ONE
456
+ // number: handing it to both is what makes the advertised ceiling and the
457
+ // enforced ceiling the same ceiling (the standing hard line, in its money
458
+ // form). `null` travels EXPLICITLY rather than being left off — unbounded is a
459
+ // stated operator choice, and an absent field read as falsy is the same state
460
+ // arrived at by accident.
461
+ /** @type {any} */
462
+ let survey = scout;
463
+ // `== null` and not `!survey`: this is the `??` these two lines replaced, kept
464
+ // exactly — a caller-supplied survey is used whatever it is, and only an
465
+ // ABSENT one is paid for.
466
+ if (survey == null) {
467
+ // The scout is the pipeline's FIRST paid stage and its longest single
468
+ // silence: up to three ladder attempts, each a real toolable survey over a
469
+ // real tree. `attempts` is the ceiling the scout enforces on itself, so the
470
+ // reader knows how many the line could repeat for.
471
+ onPhase('scout', { attempts: SCOUT_ATTEMPTS });
472
+ survey = await scoutFn({ workdir, provider, ceilingUsd, onCall });
473
+ // …and the survey's own VERDICT, said the moment it lands. `state` is what
474
+ // every downstream refusal keys on (F59: absent is never "no facts needed"),
475
+ // and the fact count is the one number that says whether the ladder came
476
+ // back with anything to author from.
477
+ onPhase('scout-done', { state: survey?.state ?? null, facts: Object.keys(survey?.facts ?? {}).length });
478
+ }
479
+ /** @type {any} */
480
+ let seeds = listing;
481
+ if (seeds == null) {
482
+ onPhase('listing', {});
483
+ seeds = await listingFn({
484
+ workdir, seedRef: seed, sourcePaths: survey?.facts?.sourcePaths, testPaths: survey?.facts?.testPaths,
485
+ });
486
+ // the count is only knowable AFTER, so it rides the done line rather than
487
+ // being guessed onto the start one
488
+ onPhase('listing-done', { files: seeds?.files?.length ?? null, stop: seeds?.stop ?? null });
489
+ }
444
490
 
491
+ // The declaration ladder. The REVISION CAP is deliberately not quoted here:
492
+ // the tighten-only clamp lives in `authorClose` (and `authorOpts` may lower
493
+ // it), so a number spelled at this call site could disagree with the one the
494
+ // loop obeys — `author-call` carries the enforced cap instead, from where it
495
+ // is computed.
496
+ onPhase('author', {});
445
497
  const authored = await authorFn({
446
498
  workdir, seedRef: seed, lang, verdictType: picked,
447
499
  answers: interview.answers, questions: questions ?? questionsFor(picked),
448
- scout: survey, listing: seeds, generate, ...authorOpts,
500
+ scout: survey, listing: seeds, generate, ceilingUsd, onPhase, onCall, ...authorOpts,
449
501
  });
450
502
 
451
503
  if (!authored.ok) {