bareloop 0.9.0 → 0.10.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
@@ -417,6 +417,86 @@ export const TYPES_GENRE = Object.freeze({
417
417
  /** no import-resolution hazard on this runner — an EMPTY list, declared,
418
418
  * never an absent field that would read as "not looked into" (F59) */
419
419
  env: Object.freeze([]),
420
+ instruments: Object.freeze([
421
+ Object.freeze({
422
+ id: 'tsc-error-line',
423
+ what: 'one type error, as the TypeScript checker prints it',
424
+ lineMatch: 'error TS\\d+',
425
+ capture: null,
426
+ // REAL, from the captured `npm run typecheck -- --strict` of the pulselog
427
+ // patient (2026-08-09): 67 error lines, every one in this shape.
428
+ example: 'src/backup.js(21,19): error TS7006: Parameter \'now\' implicitly has an \'any\' type.',
429
+ why: 'tsc prints a DIFFERENT shape on a terminal (`src/backup.js:21:19 - error TS7006:`) than through a '
430
+ + 'pipe, and a close only ever reads the piped one. Run msmbpjk6 composed the terminal shape: it matched '
431
+ + '0 of 67 real error lines while the tool exited 2, so the stage read a crashed instrument. This pattern '
432
+ + 'is anchored on the CODE, not on the path spelling, so it reads both shapes and cannot lose that flip.',
433
+ }),
434
+ // ── node --test, the SAME pipe-vs-terminal trap, MEASURED ──────────────
435
+ //
436
+ // PROVENANCE, two independent halves.
437
+ //
438
+ // (1) REAL CAPTURED OUTPUT. The mailproof job-#2 battery's own spines carry
439
+ // whole `npm test` → `node --test` captures inside their `close-precheck`
440
+ // gap: `bareloop-patients/mailproof-job2-bareloop/battery-P1-mrm7gk25.jsonl`
441
+ // (`# tests 317` / `# pass 312` / `# fail 5`, five `not ok` lines) and
442
+ // `battery-P2-mrm7gk25.jsonl` (`# pass 316` / `# fail 1`). Nothing there was
443
+ // written to match a pattern — it is what the runner printed down the close's
444
+ // pipe, months before this table existed.
445
+ //
446
+ // (2) A SHIPPED, LIVE-FIRED SPELLING. `jobs/pulselog-author-types.json`
447
+ // already grades `^# pass (\d+)$` and `^# fail (\d+)$` (stages `tests-kept`
448
+ // and `test-suite-zero-failures`), and run msoc6t8v graded green through it.
449
+ // The table is not minting a new ruler; it is HANDING OVER the one the tree
450
+ // already grades with, the way the tsc term does.
451
+ //
452
+ // WHY IT IS THE TSC TRAP AGAIN, measured on this machine (node v22.22.2,
453
+ // one test file, identical args, 2026-08-13): through a PIPE the runner
454
+ // prints TAP — `TAP version 13`, `# pass 1`, `not ok 2 - b`; on a real pty it
455
+ // prints the spec reporter — `ℹ pass 1`, no `#`, no TAP at all. A close only
456
+ // ever reads the piped half, and a pattern composed against the pretty half
457
+ // matches nothing while the suite runs fine. Exactly what cost run msmbpjk6
458
+ // its authoring pass on tsc, waiting behind a second tool.
459
+ Object.freeze({
460
+ id: 'node-test-total-count',
461
+ what: 'how many tests the runner EXECUTED, off the TAP summary',
462
+ lineMatch: '^# tests (\\d+)$',
463
+ capture: 1,
464
+ example: '# tests 317',
465
+ why: 'the EXECUTED count is the honest denominator: a passed-count floor is satisfiable by a suite that '
466
+ + 'stopped running, so a stage asking "is the suite still whole?" counts this, not passes. Anchored '
467
+ + 'both ends because the runner prefixes ordinary chatter with the same `# ` (`# Subtest: <name>`), and '
468
+ + 'an unanchored `# tests` would count test NAMES.',
469
+ }),
470
+ Object.freeze({
471
+ id: 'node-test-pass-count',
472
+ what: 'how many tests passed, off the TAP summary',
473
+ lineMatch: '^# pass (\\d+)$',
474
+ capture: 1,
475
+ example: '# pass 316',
476
+ why: 'the spelling `jobs/pulselog-author-types.json` already grades with, green through run msoc6t8v. '
477
+ + 'The runner prints this line on a green run too, so a zero here is a REPORTED zero.',
478
+ }),
479
+ Object.freeze({
480
+ id: 'node-test-fail-count',
481
+ what: 'how many tests failed, off the TAP summary',
482
+ lineMatch: '^# fail (\\d+)$',
483
+ capture: 1,
484
+ example: '# fail 1',
485
+ why: 'this runner prints every counter on every run — a fully green suite still prints `# fail 0` '
486
+ + '(measured), so reading the figure cannot confuse "no failures" with "never reported". Its pytest '
487
+ + 'counterpart has no such guarantee, which is why python has no fail-COUNT term.',
488
+ }),
489
+ Object.freeze({
490
+ id: 'node-test-failed-line',
491
+ what: 'one failing test, as the TAP reporter names it',
492
+ lineMatch: '^not ok \\d+ ',
493
+ capture: null,
494
+ example: 'not ok 7 - edit-renotify: a non-participant edit, and edits on a pending event, ping no one',
495
+ why: 'one line per failing test, so it TALLIES and no match is a genuine counted zero. The trailing '
496
+ + 'space and the `^` both carry weight: every passing test prints `ok 7 - …`, and without the anchor '
497
+ + 'each of those would count as a failure.',
498
+ }),
499
+ ]),
420
500
  }),
421
501
  python: Object.freeze({
422
502
  suppressions: Object.freeze([
@@ -437,6 +517,58 @@ export const TYPES_GENRE = Object.freeze({
437
517
  + 'found by probe). It moves no seed number, so no seed-verdict read can see its absence.',
438
518
  }),
439
519
  ]),
520
+ instruments: Object.freeze([
521
+ Object.freeze({
522
+ id: 'mypy-error-line',
523
+ what: 'one type error, as mypy prints it',
524
+ lineMatch: ' error: ',
525
+ capture: null,
526
+ // REAL, from a live aurora run's own spine
527
+ // (bareloop-patients/aurora-u-bareloop/u-ms2c0ls7.jsonl).
528
+ example: 'packages/spawner/src/aurora_spawner/timeout_policy.py:89: error: Statement is unreachable [unreachable]',
529
+ why: 'the operator\'s own hand-written close reads mypy this way (scripts/u-spawner-close.mjs:133), minted '
530
+ + 'by paid live runs. It is anchored on the SEVERITY word, not on the path spelling, so a relative or '
531
+ + 'absolute path, a package prefix or a config-driven root all read the same.',
532
+ }),
533
+ // ── pytest, from the aurora battery's own captured runs ────────────────
534
+ //
535
+ // PROVENANCE: six `close-precheck` gaps in
536
+ // `bareloop-patients/aurora-soar-bareloop/battery-A{1..4}-*.jsonl` carry the
537
+ // whole captured pytest run of the aurora patient (pytest 9.1.1, python
538
+ // 3.14.6, the patient's own `pytest.ini` addopts, which include `-ra`) —
539
+ // 2,690-odd tests, the `short test summary info` section and the final counts
540
+ // line. Real, uncrafted, paid for.
541
+ //
542
+ // WHAT IS NOT ANCHORED, and why. Every archived counts line arrives padded
543
+ // with `=` on both sides, and the padding WIDTH is terminal-derived: the
544
+ // archived long line gets exactly one `=` per side, a short local run gets
545
+ // twenty-five (measured, pytest 9.0.2, 2026-08-13). Under `-q` the padding is
546
+ // absent entirely. So the term anchors on the WORD and its number, never on
547
+ // the decoration — the same discipline as anchoring tsc on the code rather
548
+ // than the path.
549
+ Object.freeze({
550
+ id: 'pytest-pass-count',
551
+ what: 'how many tests passed, off pytest\'s final counts line',
552
+ lineMatch: '(\\d+) passed',
553
+ capture: 1,
554
+ example: '= 1 failed, 2690 passed, 3 skipped, 18 deselected, 1775 warnings in 326.12s (0:05:26) =',
555
+ why: 'the ONE segment of this line pytest prints whether the run was green or red, so it is the only '
556
+ + 'count on it that can be read as a figure without risking "never reported". The other segments '
557
+ + '(failed, skipped, deselected) are omitted when their count is zero — a green run prints just '
558
+ + '`1 passed in 0.04s` (measured) — so a term reading THOSE would go silent exactly when the news is '
559
+ + 'good, and a silent figure term is a broken instrument, not a zero.',
560
+ }),
561
+ Object.freeze({
562
+ id: 'pytest-failed-line',
563
+ what: 'one failing test, as pytest\'s short summary names it',
564
+ lineMatch: '^FAILED ',
565
+ capture: null,
566
+ example: 'FAILED packages/soar/tests/test_agent_registry_deprecation.py::TestAgentRegistryDeprecation::test_registry_and_discovery_equivalent_results',
567
+ why: 'this is how you count pytest FAILURES: exactly one line per failing test (measured under both `-ra` '
568
+ + 'and `-q`), so it TALLIES and no match at all is a legitimate counted zero rather than an unreported '
569
+ + 'number. It needs pytest\'s short summary section, which the patient\'s own config must not suppress.',
570
+ }),
571
+ ]),
440
572
  }),
441
573
  }),
442
574
  });
@@ -599,6 +731,64 @@ export function genreOwnedEnvNames(lang) {
599
731
  return language(lang).env.map((/** @type {any} */ e) => e.name);
600
732
  }
601
733
 
734
+ /**
735
+ * HOW THE GENRE'S OWN TOOLS PRINT — genre property, like the suppression battery
736
+ * and like `MYPYPATH`, and for the same reason: it is a FACT ABOUT AN INSTRUMENT
737
+ * WE ALREADY OWN, not a composition choice. Seven hand-written closes read tsc
738
+ * with the identical `/error TS\d+/` (`scripts/u-pulselog-close.mjs:122`,
739
+ * `u-bareguard-close.mjs:123`, `u-bareagent-close.mjs:123`,
740
+ * `u-baremobile-close.mjs:122`, `u-litectx-close.mjs:140`, `types-close.mjs:190`,
741
+ * `types-check.mjs:60`) and one reads mypy with `' error: '`
742
+ * (`u-spawner-close.mjs:133`) — the same provenance the SUPPRESSIONS table has:
743
+ * operator knowledge minted by paying for live runs.
744
+ *
745
+ * WHY IT BECAME GENRE DATA (run msmbpjk6, 2026-08-09, $0.43). Left to the model,
746
+ * the parser for a known tool is a DRAFT-TIME LOTTERY on a fact nobody needed to
747
+ * guess: that run composed tsc's TTY-only pretty shape
748
+ * (`^\S+\.js:\d+:\d+ - error TS\d+:`), which matched 0 of the 67 real error lines
749
+ * the piped run actually printed, while the tool exited 2. The gates held — the
750
+ * stage read as a crashed instrument and signing was refused, correctly — but the
751
+ * refusal cost a whole authoring run to learn something the tree already knew. An
752
+ * earlier run (mslwbkz7) happened to roll the right shape. A coin-flip on an
753
+ * owned fact is not a capability question.
754
+ *
755
+ * WHAT THIS IS NOT: it does not close the parser. A stage counting something we
756
+ * have NOT measured still writes its own pattern, and that remains a real gap —
757
+ * named, not smoothed. This table is where a measured instrument fact lands, one
758
+ * at a time, with its provenance.
759
+ *
760
+ * THE TEST-COUNT FACTS (2026-08-13) landed under exactly that rule, and closed
761
+ * two of the three gaps the paragraph above used to name by hand: the executed
762
+ * count and the failure line, for both runners, are now measured facts rather
763
+ * than draft-time lotteries. Provenance rides on each entry.
764
+ *
765
+ * WHAT STAYED OUT, deliberately, because no evidence covers it:
766
+ * - a pytest FAIL-COUNT figure. A green pytest run omits the segment entirely
767
+ * (`1 passed in 0.04s`), so a figure term over it reports "never reported" on
768
+ * precisely the runs that went well. Failures are counted with the `^FAILED `
769
+ * tally instead, where no match IS zero.
770
+ * - a pytest EXECUTED count. `collected N items` is printed under `-ra` and NOT
771
+ * under `-q` (measured both ways), so no single spelling survives the modes a
772
+ * patient may configure; a stage needing it still composes its own and says so.
773
+ *
774
+ * Returns a fresh copy per call, for the reason `classGuards` does: a frozen
775
+ * constant handed to a caller that edits it is one shared table away from a
776
+ * silently changed ruler.
777
+ * @param {string} lang
778
+ * @returns {{id: string, what: string, lineMatch: string, capture: number|null, example: string, why: string}[]}
779
+ */
780
+ export function genreInstruments(lang) {
781
+ const instruments = language(lang).instruments ?? [];
782
+ // F59's shape at a table that feeds a RULER: an empty list would read as "this
783
+ // language's tools need no stated format", which is exactly the sentence that
784
+ // put the pretty-format regex into a signed-close candidate.
785
+ if (!instruments.length) {
786
+ throw new Error(`no known-instrument formats for language "${lang}" — a language the genre admits states how its `
787
+ + 'own checker prints, or the model is back to guessing a fact we already own');
788
+ }
789
+ return instruments.map((/** @type {any} */ i) => ({ ...i }));
790
+ }
791
+
602
792
  /**
603
793
  * GROUNDING for a RECORDED genre environment — the provenance half of the
604
794
  * owned-env rule, and the reason the by-value comparison is worth anything.
package/src/authorjob.js CHANGED
@@ -365,7 +365,7 @@ 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
369
  * seedFn?: Function, scoutFn?: Function, listingFn?: Function,
370
370
  * authorFn?: Function, authorOpts?: object}} o
371
371
  * @returns {Promise<{ok: boolean, refusal: Refusal|null, verdictType: string|null,
@@ -375,6 +375,7 @@ function composerRefusal(reds) {
375
375
  export async function authorCloseForJob({
376
376
  answers, repoPath = null, lang, verdictType = null, questions = null,
377
377
  generate, provider = null, seedRef = null, scout = null, listing = null,
378
+ ceilingUsd = null,
378
379
  seedFn = seedAtHead, scoutFn = runAuthorScout, listingFn = buildSeedListing,
379
380
  authorFn = authorClose, authorOpts = {},
380
381
  }) {
@@ -437,7 +438,14 @@ export async function authorCloseForJob({
437
438
  // D11 — the scout LOOKS, the author WRITES, neither acts. An ABSENT survey is
438
439
  // never "no special facts are needed" (F59), and `authorClose` refuses on it at
439
440
  // $0, so nothing here has to second-guess the classification.
440
- const survey = scout ?? await scoutFn({ workdir, provider });
441
+ // ONE CEILING, BOTH PAID SEAMS. The survey and the declaration loop are two
442
+ // populations of paid call with two accumulators, and the operator set ONE
443
+ // number: handing it to both is what makes the advertised ceiling and the
444
+ // enforced ceiling the same ceiling (the standing hard line, in its money
445
+ // form). `null` travels EXPLICITLY rather than being left off — unbounded is a
446
+ // stated operator choice, and an absent field read as falsy is the same state
447
+ // arrived at by accident.
448
+ const survey = scout ?? await scoutFn({ workdir, provider, ceilingUsd });
441
449
  const seeds = listing ?? await listingFn({
442
450
  workdir, seedRef: seed, sourcePaths: survey?.facts?.sourcePaths, testPaths: survey?.facts?.testPaths,
443
451
  });
@@ -445,7 +453,7 @@ export async function authorCloseForJob({
445
453
  const authored = await authorFn({
446
454
  workdir, seedRef: seed, lang, verdictType: picked,
447
455
  answers: interview.answers, questions: questions ?? questionsFor(picked),
448
- scout: survey, listing: seeds, generate, ...authorOpts,
456
+ scout: survey, listing: seeds, generate, ceilingUsd, ...authorOpts,
449
457
  });
450
458
 
451
459
  if (!authored.ok) {
@@ -50,11 +50,11 @@ import { createRequire } from 'node:module';
50
50
  import { join } from 'node:path';
51
51
  import { Gate } from 'bareguard';
52
52
  import { LiteCtx } from 'litectx';
53
- import { seedListing } from './kinds.js';
53
+ import { seedListing, GATE_AUDIT_FILE, ARBITER_BOOK_STORES } from './kinds.js';
54
54
  import { TOOL_MENU, WRITE_VERBS, STORE_VERBS } from './job.js';
55
55
  import { TOOL_BY_VERB, CTX_TOOLS, createCtxTools, toolAction, strategyFor } from './tools.js';
56
56
  import { redactSecrets, SECRET_PATTERNS } from './validate.js';
57
- import { extractArtifact, priceOf, scrubRaw, stampRaw } from './text.js';
57
+ import { extractArtifact, priceOf, scrubRaw, stampRaw, tallyCalls, capStop } from './text.js';
58
58
 
59
59
  const require = createRequire(import.meta.url);
60
60
  const { Loop, wireGate } = require('bare-agent');
@@ -115,6 +115,16 @@ export const SURVEY_CAUSES = Object.freeze({
115
115
  NON_OBJECT: 'non-object',
116
116
  /** valid JSON, no content — F59's whole point, and a SEMANTIC failure */
117
117
  EMPTY_OBJECT: 'empty-object',
118
+ /**
119
+ * THE SURVEY WAS NEVER ASKED FOR: the operator's money ceiling was already
120
+ * spent when the first attempt came due, so no call was made. Named as its own
121
+ * cause for the reason this enum exists at all — a new route to ABSENT has to
122
+ * be named, and naming it is the moment somebody decides whether it retries.
123
+ * This one does not, and cannot: a retry is precisely what the cap forbade.
124
+ * It is deliberately ABSENT of the model's own doing, and the prose says so —
125
+ * "the scout did not complete" would blame a model that never spoke.
126
+ */
127
+ NOT_FUNDED: 'not-funded',
118
128
  });
119
129
 
120
130
  /**
@@ -280,9 +290,9 @@ export function classifySurvey(blob, meta) {
280
290
  * `runAuthorScout` where they can be proven.
281
291
  * @param {{workdir: string, granted: readonly string[], auditPath?: string, ctx?: boolean}} o
282
292
  */
283
- export async function defaultSurveyor({ workdir, granted, auditPath = join(workdir, 'gate-audit.jsonl'), ctx = true }) {
293
+ export async function defaultSurveyor({ workdir, granted, auditPath = join(workdir, GATE_AUDIT_FILE), ctx = true }) {
284
294
  const gate = new Gate({
285
- fs: { writeScope: [], readScope: [workdir], deny: [auditPath, join(workdir, '.smoke'), join(workdir, '.litectx')] },
295
+ fs: { writeScope: [], readScope: [workdir], deny: [auditPath, ...ARBITER_BOOK_STORES.map((s) => join(workdir, s))] },
286
296
  limits: { maxTurns: AUTHOR_SCOUT_ROUNDS + 2 },
287
297
  audit: { path: auditPath },
288
298
  secrets: { patterns: SECRET_PATTERNS },
@@ -338,11 +348,12 @@ const defaultLoop = (/** @type {any} */ { system, policy, onLlmResult, provider
338
348
  *
339
349
  * @param {{workdir: string, provider?: any,
340
350
  * rounds?: number, minBytes?: number, blobMax?: number, maxTokens?: number, ctx?: boolean,
341
- * attempts?: number,
351
+ * attempts?: number, ceilingUsd?: number|null,
342
352
  * createLoop?: (o: {system: string, policy: any, onLlmResult: any, provider: any}) => any,
343
353
  * createSurveyor?: (o: {workdir: string, granted: readonly string[], ctx: boolean}) => Promise<any>}} o
344
354
  * @returns {Promise<Survey & {raw: string, raws: ReturnType<typeof scrubRaw>[],
345
- * calls: {label: string, costUsd: number|null, unpricedRounds: number}[]}>}
355
+ * calls: {label: string, costUsd: number|null, unpricedRounds: number}[],
356
+ * budgetStop: 'cap-halt'|'pricing-red'|null}>}
346
357
  */
347
358
  export async function runAuthorScout({
348
359
  workdir, provider = null,
@@ -352,6 +363,7 @@ export async function runAuthorScout({
352
363
  maxTokens = AUTHOR_SCOUT_MAX_TOKENS,
353
364
  ctx = true,
354
365
  attempts = SCOUT_ATTEMPTS,
366
+ ceilingUsd = null,
355
367
  createLoop = defaultLoop,
356
368
  createSurveyor = defaultSurveyor,
357
369
  }) {
@@ -395,8 +407,21 @@ export async function runAuthorScout({
395
407
  * judged. An instrument that misaddresses its own reading is the blind
396
408
  * -instrument class, one field wide. */
397
409
  let verdictAt = -1;
410
+ /** the operator's money ceiling, when it is what ended the ladder. `null` on
411
+ * every other path INCLUDING the unbounded one, so a reader never has to
412
+ * infer a governance stop from an absence.
413
+ * @type {'cap-halt'|'pricing-red'|null} */
414
+ let budgetStop = null;
398
415
 
399
416
  while (attempt < allowed) {
417
+ // THE CEILING, between attempts. The attempt cap bounds how many times the
418
+ // survey may ask; it says nothing about what those asks cost, and this is
419
+ // the pipeline's FIRST paid stage — a ladder that spends the whole wallet
420
+ // before the declaration is ever authored is the failure this closes.
421
+ // Checked before the counter moves, so `attempt` stays the number of
422
+ // attempts that actually happened.
423
+ const halt = capStop({ ceilingUsd, ...tallyCalls(calls) });
424
+ if (halt) { budgetStop = halt; break; }
400
425
  attempt += 1;
401
426
  const label = attempt === 1 ? 'author-scout' : `author-scout#${attempt}`;
402
427
 
@@ -432,7 +457,29 @@ export async function runAuthorScout({
432
457
  // F59's reserved TOOLLESS final round. It fires on the ONE population it
433
458
  // was sized for — a survey that was CUT OFF and came back short — never
434
459
  // on a survey that simply finished with little to say.
435
- if (bounded && Buffer.byteLength(blob) < minBytes && Array.isArray(r?.msgs) && r.msgs.length) {
460
+ // …and the reserved round is a PAID CALL like any other, so it sits behind
461
+ // the same ceiling. Left outside it, the one call the cap most needs to
462
+ // stop is the one that escapes: the recovery fires exactly when the first
463
+ // call was cut off, which is exactly when the wallet is thinnest.
464
+ //
465
+ // THE CEILING IS ASKED ABOUT A CALL THAT WOULD ACTUALLY BE MADE, and the
466
+ // ORDER is what makes that true: the recovery predicate is settled FIRST,
467
+ // and `capStop` is consulted only when a recovery was genuinely pending.
468
+ // Asked unconditionally it answers a question nobody put — the wallet is
469
+ // over, but nothing was refused — and `budgetStop` means the ceiling is
470
+ // WHAT ENDED THE LADDER, which is then a lie on every other attempt-1
471
+ // ending: a dead socket on an unbounded first call, or a survey that came
472
+ // back PRESENT and needed no second call at all. Both then render as
473
+ // `cap-halt` at `path: budgetUsd`, steering the operator to raise a number
474
+ // that cannot move the outcome. Nothing about the REFUSAL moves here — the
475
+ // same predicate, the same tally, the same call not made (the top-of-loop
476
+ // latch still names the ceiling whenever a retry was due). This is which
477
+ // ending gets the label, and nothing else.
478
+ const recoveryWanted = bounded && Buffer.byteLength(blob) < minBytes
479
+ && Array.isArray(r?.msgs) && r.msgs.length > 0;
480
+ const recoveryHalt = recoveryWanted ? capStop({ ceilingUsd, ...tallyCalls(calls) }) : null;
481
+ if (recoveryHalt) budgetStop = recoveryHalt;
482
+ if (recoveryWanted && !recoveryHalt) {
436
483
  const recovery = createLoop({ system, policy: surveyor.policy, onLlmResult: surveyor.onLlmResult, provider });
437
484
  s2 = await recovery.run([...r.msgs, { role: 'user', content: SCOUT_RECOVERY_PROMPT }], [],
438
485
  { cacheMessages: true, maxTokens });
@@ -484,12 +531,26 @@ export async function runAuthorScout({
484
531
  if (!SCOUT_RETRY_CAUSES.includes(/** @type {string} */ (survey.cause))) break;
485
532
  }
486
533
 
487
- const final = /** @type {Survey} */ (survey);
534
+ // THE SURVEY THAT WAS NEVER ASKED FOR. The ceiling was already spent when the
535
+ // first attempt came due, so no call was made and there is no reply to
536
+ // classify. It is spelled out rather than run through `classifySurvey`, which
537
+ // would read the empty blob and report that the model came back EMPTY — a
538
+ // model that never spoke cannot have said nothing, and blaming it would send
539
+ // the operator to the scout when the fact is the wallet.
540
+ const final = /** @type {Survey} */ (survey ?? {
541
+ state: 'ABSENT',
542
+ facts: null,
543
+ cause: SURVEY_CAUSES.NOT_FUNDED,
544
+ reason: budgetStop === 'pricing-red'
545
+ ? 'the survey was never asked for: spend could not be priced, so the ceiling could not be enforced (F6)'
546
+ : `the survey was never asked for: the $${ceilingUsd} ceiling was already spent`,
547
+ meta: { bytes: 0, rounds: 0, bounded: false, recovered: false, error: null, attempts: 0, attemptsAllowed: allowed },
548
+ });
488
549
  // Exhaustion is still the same honest ABSENT — it now says how many times we
489
550
  // paid to find out. One attempt says nothing about retries, because there
490
551
  // were none.
491
552
  const reason = final.reason !== null && attempt > 1 ? `${final.reason} — after ${attempt} attempts` : final.reason;
492
- return { ...final, reason, raw: blob, raws, calls };
553
+ return { ...final, reason, raw: blob, raws, calls, budgetStop };
493
554
  } finally {
494
555
  // a leaked surveyor leaks a gate handle and a litectx index — always, even
495
556
  // when the loop threw
package/src/bridges.js CHANGED
@@ -34,7 +34,7 @@
34
34
 
35
35
  import { existsSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync, statSync, writeFileSync } from 'node:fs';
36
36
  import { join } from 'node:path';
37
- import { isObj, isNonEmptyString } from './validate.js';
37
+ import { isObj, isNonEmptyString, sweepNestedQuantifiers } from './validate.js';
38
38
  import { TOOL_MENU, LOCKED_TOOLS } from './job.js';
39
39
  import { closeStagesOf } from './plan.js';
40
40
 
@@ -183,6 +183,19 @@ export function validateBridge(input) {
183
183
  if (!Array.isArray(e.history)) red('invalid-value', 'history', 'array of per-run rows');
184
184
  else e.history.forEach((/** @type {unknown} */ h, /** @type {number} */ i) => validateHistoryRow(h, `history.${i}.`, red));
185
185
 
186
+ // PRD v1.60 §1 — the operator-regex admissibility sweep, whose population was
187
+ // `jobs/*.json` and is now every signed spec wherever it lives. A bridge ships
188
+ // its plans VERBATIM, and a stored `artifact-written.pattern` reaches the same
189
+ // untimed exit evaluator on the same main event loop that F49 measured. The
190
+ // reject is UNCHANGED (`hasNestedQuantifier`, one inventory) — only the
191
+ // population is wider, so a clean bridge is byte-identically clean.
192
+ //
193
+ // Whole-entry rather than per-field, because a bridge's regexes sit inside a
194
+ // plan stored as opaque data (`isObj(o.plan)` is all this validator asserts
195
+ // about it): enumerating a path into a document this one deliberately does not
196
+ // parse is how a sweep goes blind the next time the plan schema moves.
197
+ sweepNestedQuantifiers(e, red);
198
+
186
199
  // R1's coherence, checked only once everything it reads is itself valid (a
187
200
  // second red on an already-broken field would bury the real defect): every green
188
201
  // mints exactly one version, and a red mints none.
package/src/index.js CHANGED
@@ -96,6 +96,9 @@ export {
96
96
  export {
97
97
  validateDeclaration, normalizeDeclaration, KIND_CATALOGUE, CATALOGUE_KINDS, CATALOGUE_LIVE_KINDS,
98
98
  LOCKED_KINDS, TYPES_GENRE, TYPES_GENRE_TEMPLATE, GENRE_LANGUAGES, genreEnv, genreOwnedEnvNames,
99
+ // how the genre's own tools PRINT — genre property for the same reason the
100
+ // suppression battery is (2026-08-09, run msmbpjk6)
101
+ genreInstruments,
99
102
  DIRECTIONS, BASELINES, MAX_STAGES,
100
103
  // the verdict-class surface (PRD v1.57 §1–§2): the radio's own menu, the guard
101
104
  // battery keyed off it, and the ceiling that makes the pick a promise
package/src/kinds.js CHANGED
@@ -346,24 +346,55 @@ export async function seedListing(workdir, seedRef) {
346
346
  // ── contract (c): the changed-set primitive ──────────────────────────────────
347
347
 
348
348
  /**
349
- * The two arbiter books, and the shape of each exclusion is the whole point.
349
+ * The arbiter books, and the shape of each exclusion is the whole point.
350
350
  *
351
- * `.litectx/` is a PREFIX — everything beneath the store belongs to the
352
- * arbiter. `gate-audit.jsonl` is an EXACT repo-relative path and never a
353
- * basename or a pattern: the gate audit is written to
351
+ * `.litectx/` and `.smoke/` are PREFIXES — everything beneath either store
352
+ * belongs to the arbiter. `gate-audit.jsonl` is an EXACT repo-relative path and
353
+ * never a basename or a pattern: the gate audit is written to
354
354
  * `join(workdir, 'gate-audit.jsonl')` and nowhere else, so a worker-authored
355
355
  * `src/gate-audit.jsonl` is NOT an arbiter book and must still count as work.
356
356
  * Matching by basename would swallow it — and swallowing a worker's real
357
- * write is the failure direction that reads as a clean tree.
357
+ * write is the failure direction that reads as a clean tree. `src/smoke.js` and
358
+ * `tests/smoke.test.js` are that same decoy for the store below.
359
+ *
360
+ * `.smoke/` — F98's parked residual, closed here. The fence has always denied
361
+ * `join(workdir, '.smoke')` on every worker (src/planrun.js, src/authorscout.js)
362
+ * and the persona now states the rule; this reader, which decides what COUNTS AS
363
+ * WORK, was the one place that never knew the name. On patients whose .gitignore
364
+ * default-denies dot-directories the store never reaches `ls-files --others` and
365
+ * the gap is masked — by the PATIENT's config, not by anything here. Incidental
366
+ * masking is not an exclusion: on a patient without that line the arbiter's own
367
+ * store would enter the changed set as the worker's writing.
358
368
  * @param {string} rel a repo-relative POSIX path
359
369
  * @returns {string|null} the book it belongs to, or null
360
370
  */
361
371
  export function isArbiterBook(rel) {
362
- if (rel === '.litectx' || rel.startsWith('.litectx/')) return '.litectx/';
363
- if (rel === 'gate-audit.jsonl') return 'gate-audit.jsonl';
372
+ for (const store of ARBITER_BOOK_STORES) {
373
+ if (rel === store || rel.startsWith(`${store}/`)) return `${store}/`;
374
+ }
375
+ if (rel === GATE_AUDIT_FILE) return GATE_AUDIT_FILE;
364
376
  return null;
365
377
  }
366
378
 
379
+ /**
380
+ * The arbiter's book NAMES, one home. F98's mechanism was exactly this list
381
+ * living as independent literals: the fence deny arrays (src/planrun.js,
382
+ * src/authorscout.js) knew `.smoke` while `isArbiterBook` — the reader that
383
+ * decides what COUNTS AS WORK — did not, and the drift was masked by the
384
+ * patient's own .gitignore. Every consumer (the fence deny lists, this file's
385
+ * reader, the persona's stated law, run.js's smoke-store root) now spells the
386
+ * names from here, so adding or renaming a book is one edit that cannot leave
387
+ * a site behind.
388
+ *
389
+ * `ARBITER_BOOK_STORES` are directory PREFIXES; `GATE_AUDIT_FILE` is an EXACT
390
+ * repo-relative path (see `isArbiterBook`'s docstring for why never a
391
+ * basename). Order is the fence deny-list order and the persona's prose order.
392
+ */
393
+ export const SMOKE_STORE = '.smoke';
394
+ export const LITECTX_STORE = '.litectx';
395
+ export const GATE_AUDIT_FILE = 'gate-audit.jsonl';
396
+ export const ARBITER_BOOK_STORES = Object.freeze([SMOKE_STORE, LITECTX_STORE]);
397
+
367
398
  /**
368
399
  * Everything this run changed against the seed: the tracked diff PLUS
369
400
  * untracked files (an untracked file has no diff — the case a naive
@@ -699,13 +730,36 @@ export function normalizeParser(parser, at = 'parser') {
699
730
  * that matched BEFORE the filter judged them — kept, unattributable and dropped
700
731
  * alike — and it is the only evidence of liveness a caller has once the scope
701
732
  * has removed the whole population. It is never the number being read.
733
+ *
734
+ * `matched` is the KEPT lines themselves, PER TERM and index-aligned with
735
+ * `breakdown`: entry i holds, in output order, exactly the lines term i's
736
+ * subtotal was computed FROM. Run u-msn227nq paid for the echo — the executor
737
+ * counted 8 real `error TS…` lines and told the worker "8 match(es)", discarding
738
+ * every address, and a worker with a number and no file to open went looking for
739
+ * the arbiter's own books instead.
740
+ *
741
+ * TWO EXCLUSIONS, both of them "this line is not what the count is made of":
742
+ * - a scope-DROPPED line is never in here. It was never counted, so aiming the
743
+ * worker at it would aim it outside its own population.
744
+ * - under `first`, only the line the aggregate actually READ. The later matches
745
+ * are real matches (`breakdown[i].matches` still counts them) but the value
746
+ * never contained them, and echoing them points the worker at a number that
747
+ * is not in the reading.
748
+ *
749
+ * And the GROUPING is the third: a term's sign lives in `breakdown[i]`, so lines
750
+ * pooled flat across terms lose the one fact that says what they DID to the
751
+ * number. A subtracted term's line rendered beside an added one reads as a wall
752
+ * to go fix, and fixing it moves the count the wrong way. Deduplication is
753
+ * therefore within a term only — one line matched by two terms plays two
754
+ * arithmetic roles and is named under each. The count is computed from `values`,
755
+ * never from this list.
702
756
  * @param {string} output @param {any[]} terms normalised terms
703
757
  * Every stop here is `crashed` in the arbiter's vocabulary — the command RAN,
704
758
  * came back, and the number could not be read out of it. Nothing in this
705
759
  * function can observe a spawn failure, a timeout or a signal, so it never
706
760
  * claims one.
707
761
  * @param {{scope?: any, workdir?: string}} [o]
708
- * @returns {{stop: string, fault: string, notes?: string[]}|{stop: null, value: number, breakdown: any[], notes: string[], preScopeMatches: number}}
762
+ * @returns {{stop: string, fault: string, notes?: string[]}|{stop: null, value: number, breakdown: any[], notes: string[], preScopeMatches: number, matched: string[][]}}
709
763
  */
710
764
  export function parseValue(output, terms, { scope = null, workdir = '' } = {}) {
711
765
  let value = 0;
@@ -715,6 +769,10 @@ export function parseValue(output, terms, { scope = null, workdir = '' } = {}) {
715
769
  const breakdown = [];
716
770
  /** @type {string[]} */
717
771
  const notes = [];
772
+ // the KEPT lines PER TERM, in output order and deduplicated within the term —
773
+ // see the note above
774
+ /** @type {string[][]} */
775
+ const matched = [];
718
776
  const filtering = scopeFilters(scope);
719
777
  // one resolver (and one realpath cache) per call, built only when a filter
720
778
  // will actually read it
@@ -739,6 +797,9 @@ export function parseValue(output, terms, { scope = null, workdir = '' } = {}) {
739
797
  try { re = new RegExp(t.lineMatch, 'g'); } catch (e) { return { stop: `INSTRUMENT: term ${i} lineMatch /${t.lineMatch}/ is not a valid regex: ${String(/** @type {any} */ (e)?.message ?? e)}`, fault: STOP_FAULTS.CRASHED }; }
740
798
  /** @type {number[]} */
741
799
  const values = [];
800
+ /** @type {string[]} */
801
+ const kept = [];
802
+ const seen = new Set();
742
803
  let dropped = 0;
743
804
  let unattributable = 0;
744
805
  for (const line of region.split('\n')) {
@@ -750,6 +811,10 @@ export function parseValue(output, terms, { scope = null, workdir = '' } = {}) {
750
811
  if (p === null) unattributable += 1;
751
812
  else if (!inScope(p, scope, phys)) { dropped += 1; continue; }
752
813
  }
814
+ // past the filter, so this line is a CANDIDATE for the harvest — kept here
815
+ // rather than at aggregation because this is the only place the raw line
816
+ // still exists, and narrowed to what the aggregate actually read below
817
+ if (!seen.has(line)) { seen.add(line); kept.push(line); }
753
818
  for (const h of hits) {
754
819
  if (t.capture === null) { values.push(1); continue; }
755
820
  const rawCapture = h[t.capture];
@@ -789,10 +854,14 @@ export function parseValue(output, terms, { scope = null, workdir = '' } = {}) {
789
854
  }
790
855
  subtotal = values[0];
791
856
  }
857
+ // `first` read ONE line — `kept[0]`, since `values[0]` is the first hit on
858
+ // the first kept line. The rest matched (and `matches` below still says so)
859
+ // but are not what this subtotal is made of.
860
+ matched.push(t.aggregate === 'first' ? kept.slice(0, 1) : kept);
792
861
  breakdown.push({ term: i, lineMatch: t.lineMatch, aggregate: t.aggregate, sign: t.sign, matches: values.length, subtotal, contribution: t.sign * subtotal });
793
862
  value += t.sign * subtotal;
794
863
  }
795
- return { stop: null, value, breakdown, notes, preScopeMatches };
864
+ return { stop: null, value, breakdown, notes, preScopeMatches, matched };
796
865
  }
797
866
 
798
867
  // ── D12: the baseline is MEASURED at this run's own seed, every run ──────────
@@ -1084,7 +1153,7 @@ async function runCountNotWorse(stage, ctx) {
1084
1153
  notes: [],
1085
1154
  };
1086
1155
  }
1087
- return { stop: null, value: v.value, breakdown: v.breakdown, notes: v.notes, exit: r.code, dropped: r.dropped, fault: STOP_FAULTS.FAILED };
1156
+ return { stop: null, value: v.value, breakdown: v.breakdown, notes: v.notes, matched: v.matched, exit: r.code, dropped: r.dropped, fault: STOP_FAULTS.FAILED };
1088
1157
  };
1089
1158
 
1090
1159
  const now = await measure(ctx.workdir, 'the current tree');
@@ -1126,8 +1195,30 @@ async function runCountNotWorse(stage, ctx) {
1126
1195
  if (worse) {
1127
1196
  gap.push(`${stage.name}: ${value} against a baseline of ${baseline} (${p.direction}) — worse`);
1128
1197
  gap.push(` baseline ${baselineSource}`);
1129
- for (const b of /** @type {any[]} */ (now.breakdown)) {
1198
+ // …each term stating itself, and THEN the lines that term's subtotal is made
1199
+ // of, nested under it. A count with no addresses is the semantic genre F38
1200
+ // measured as inert: run u-msn227nq's worker was handed "8 match(es)", had
1201
+ // nowhere to open, and probed the arbiter's books until the deny guard ended
1202
+ // the run. The instrument NAMED these lines — echoing what it named is the
1203
+ // same licence `pattern-absent-in-diff` already runs under, not the barred
1204
+ // move of naming a culprit the instrument never reported.
1205
+ //
1206
+ // NESTED, not pooled, because a line's arithmetic role is the term's and only
1207
+ // the term's row carries it. `value` is a SIGNED sum: a term declared
1208
+ // `sign: -1` subtracts, so its lines are ones the count went DOWN for, and a
1209
+ // worker handed them flat beside the added ones reads every line as a wall
1210
+ // and "fixes" one that moves the number the wrong way. The row directly above
1211
+ // each block already says `contributes +n` / `-n`; sitting the lines under it
1212
+ // is what makes that statement reach them. Nothing is withheld — under
1213
+ // `higher-is-better` the subtracted term is exactly where the news usually is
1214
+ // — and nothing is relabelled: the lines are still verbatim, one indent in.
1215
+ //
1216
+ // They ride the Gap like every other line: the stage prefix (Layer R's
1217
+ // redKeep derives from it), the cap, and the trim marker on overflow.
1218
+ const perTerm = /** @type {string[][]} */ (now.matched ?? []);
1219
+ for (const [i, b] of /** @type {any[]} */ (now.breakdown).entries()) {
1130
1220
  gap.push(` term ${b.term} /${b.lineMatch}/ ${b.aggregate}: ${b.matches} match(es), subtotal ${b.subtotal}, contributes ${b.contribution >= 0 ? '+' : ''}${b.contribution}`);
1221
+ for (const l of perTerm[i] ?? []) gap.push(` ${l}`);
1131
1222
  }
1132
1223
  }
1133
1224
  return result(stage, {
package/src/ledger.js CHANGED
@@ -75,7 +75,11 @@ const EXCLUDED_ESCALATIONS = new Set([
75
75
  // real upstream ask would be sourced from anyway.
76
76
  'step-stalled',
77
77
  'step-variance', // A's replan trigger: a planning story, never a lib bug
78
- 'gate-red', // governance working as intended
78
+ // Governance working as intended — and no longer minted anywhere in this tree
79
+ // (a denial streak is a bounded attempt, not a terminal). Kept listed because
80
+ // this set is the EXECUTABLE excluded-set: dropping a name does not delete the
81
+ // category, it re-files any future emission of it as a counted capability gap.
82
+ 'gate-red',
79
83
  'smoke-red', // already counted via primitive-smoke
80
84
  'hitl-close', // by design: a human is the close
81
85
  'close-unsupported', // honest refusal, by design