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/validate.js CHANGED
@@ -177,6 +177,69 @@ function unboundedQuantLen(src, i) {
177
177
  return len;
178
178
  }
179
179
 
180
+ /** How many characters of `(`-prologue sit at `src[i]` before the group's BODY:
181
+ * 1 for a plain or capturing group, 3 for `(?:` `(?=` `(?!`, 4 for `(?<=` `(?<!`,
182
+ * and past the `>` of a named group `(?<name>`. The body is read back when the
183
+ * group closes, and a prologue counted as body text would make `(?:a|aa)` split
184
+ * as `?:a` / `aa` — no prefix relation, and the hazard read as safe.
185
+ * @param {string} src @param {number} i index of the `(` */
186
+ function groupPrologueLen(src, i) {
187
+ if (src[i + 1] !== '?') return 1;
188
+ const c = src[i + 2];
189
+ if (c === ':' || c === '=' || c === '!') return 3;
190
+ if (c === '<') {
191
+ if (src[i + 3] === '=' || src[i + 3] === '!') return 4; // lookbehind
192
+ const close = src.indexOf('>', i + 3); // named group
193
+ return close === -1 ? 3 : close - i + 1;
194
+ }
195
+ return 1; // modifier groups etc — read the lot as body (fail-safe)
196
+ }
197
+
198
+ /**
199
+ * Do a group body's TOP-LEVEL alternation branches overlap — is one a prefix of
200
+ * another, equality included? That is the practical detectable core of the
201
+ * overlapping-alternation blowup (`(a|aa)`, `(a|a)`, `(\d|\d\d)`, `(a|a?)`):
202
+ * two branches that can consume the same text give the engine two ways to reach
203
+ * the same position, and an unbounded repeat multiplies them.
204
+ *
205
+ * The comparison is over the branches' SOURCE TEXT, which is what makes it
206
+ * conservative rather than exact — `(ab|abc)+` carries the prefix relation and
207
+ * did not blow up on the body measured. Exactness needs the real regex engine
208
+ * F49 declined to take as a dependency, and guessing it wrong would ADMIT an
209
+ * exponential pattern, so the over-rejection stands as a named limit.
210
+ *
211
+ * Splitting respects escapes, character classes and nested groups: a `|` inside
212
+ * `[a|b]` or inside `(x|y)` is not this group's alternation, and treating it as
213
+ * one would invent branches nobody wrote.
214
+ * @param {string} body the text between a group's prologue and its `)`
215
+ * @returns {boolean}
216
+ */
217
+ function altBranchesOverlap(body) {
218
+ /** @type {string[]} */
219
+ const branches = [];
220
+ let start = 0;
221
+ let depth = 0;
222
+ for (let i = 0; i < body.length; i++) {
223
+ const c = body[i];
224
+ if (c === '\\') { i++; continue; }
225
+ if (c === '[') { i++; if (body[i] === '^') i++; while (i < body.length && body[i] !== ']') { if (body[i] === '\\') i++; i++; } continue; }
226
+ if (c === '(') { depth++; continue; }
227
+ if (c === ')') { depth--; continue; }
228
+ if (c === '|' && depth === 0) { branches.push(body.slice(start, i)); start = i + 1; }
229
+ }
230
+ if (branches.length === 0) return false; // no top-level alternation at all
231
+ branches.push(body.slice(start));
232
+ for (let i = 0; i < branches.length; i++) {
233
+ for (let j = 0; j < branches.length; j++) {
234
+ // `startsWith` covers EQUALITY, which is the 2ⁿ case: `(a|a)+$` took 52.3s
235
+ // on a 27-character body. Skipping i===j is what keeps that from being
236
+ // every pattern's own reflexive match.
237
+ if (i !== j && branches[j].startsWith(branches[i])) return true;
238
+ }
239
+ }
240
+ return false;
241
+ }
242
+
180
243
  /**
181
244
  * F49 — a heuristic reject for the catastrophic-backtracking footgun: an
182
245
  * unbounded quantifier applied to a group whose body itself repeats unboundedly
@@ -201,16 +264,40 @@ function unboundedQuantLen(src, i) {
201
264
  * So the reject targets the dominant exponential class and fails it as a
202
265
  * mechanical gap at the validation gate, before any tokens burn. A full ReDoS
203
266
  * analyzer needs a real regex engine (an external native dep we do not take for
204
- * a LOW issue); exotic overlapping-alternation blowup is out of scope by
205
- * decision (PARKED to the close-authoring rung — PRD v1.55).
267
+ * a LOW issue).
268
+ *
269
+ * SECOND CLASS, added 2026-08-12 — OVERLAPPING ALTERNATION under an unbounded
270
+ * repeat (`(a|aa)+`, `(a|a)+`, `(\d|\d\d)*`). F49 recorded this as a genuine
271
+ * false NEGATIVE of the shape approach and PARKED the widening, because changing
272
+ * the detector changes which SIGNED specs are admissible. It is closed here on
273
+ * measurement, one fresh process per point (a hot RegExp tiers up out of the V8
274
+ * interpreter, and a single-process sweep read a LONGER body as faster —
275
+ * backwards for an exponential, and the harness rather than the regex).
276
+ * `(a|aa)+$` against a run of `a` with one non-matching tail character:
277
+ *
278
+ * 27ch 218ms · 31ch 1,879ms · 35ch 12,082ms · 39ch 52,563ms · 41ch 189,420ms
279
+ *
280
+ * ~6.8× per 4 characters — the Fibonacci decomposition count, φ⁴. `(a|a)+$` (the
281
+ * 2ⁿ equal-branch case) took 52,256ms on 27ch, and `((a|aa))+$` — the redundant
282
+ * wrapper the `((a+))+` entry above exists for — 15,426ms on 35ch, which is why
283
+ * the overlap propagates up through a wrapper exactly as the inner repeat does.
284
+ * Input-bounding is theater here for F49's own reason: tens of characters suffice.
285
+ *
286
+ * The detected core is the PREFIX RELATION between top-level branches (one branch
287
+ * a prefix of another, equality included — `(a|a)+` is the 2ⁿ case). Whether a
288
+ * given prefix overlap can actually be DRIVEN exponentially depends on what
289
+ * follows the group, and deciding that needs the real engine this file declined
290
+ * to take as a dependency — so the reject is conservative by construction, which
291
+ * is the fail-safe direction.
292
+ *
293
+ * MONOTONIC, and that is the rule this change lives under: it only ever ADDS
294
+ * rejections. Nothing above is narrowed, and no previously-rejected shape became
295
+ * admissible (asserted over the whole corpus in tests/plan.test.js). F49's
296
+ * "never sharpen the detector" bars chasing false POSITIVES; closing a false
297
+ * NEGATIVE by adding rejections is the opposite, allowed direction — distinct
298
+ * rules, not in tension (the `((a+))+` widening set the precedent).
206
299
  *
207
300
  * SCOPE IS ASYMMETRIC, both directions named on purpose:
208
- * - false NEGATIVE: overlapping-alternation blowup (`(a|ab)+`-class) is not
209
- * detected — there is no inner QUANTIFIER to find, so the shape scan cannot
210
- * see it; the blowup comes from overlapping alternation BRANCHES under a
211
- * repeat. A genuine limit of the approach, not a bug in it. Widening the
212
- * detector changes which SIGNED specs are admissible, which is
213
- * arbiter-adjacent — parked, not chased (PRD v1.55).
214
301
  * - false POSITIVE: a group whose repetitions are disambiguated by a literal
215
302
  * anchor/delimiter (`(?:^- .+$\n?)+`, `(?:CHANGELOG:.+\n)+`) is FLAGGED even
216
303
  * though a real engine runs it linearly — the scan sees the nested-quantifier
@@ -221,13 +308,28 @@ function unboundedQuantLen(src, i) {
221
308
  * and guessing it wrong would ADMIT an exponential pattern — so the shape
222
309
  * reject stands, and the over-rejection is a named, accepted limitation.
223
310
  *
311
+ * The alternation class has its OWN instance of the same limit, measured and
312
+ * locked in `REDOS_OVERREJECTED_ALT`: `(ab|abc)+$`, `(x|xy|xyz)+$` and
313
+ * `(x(a|aa))+$` carry the prefix relation and did NOT blow up on the bodies
314
+ * measured (0.3–0.4ms at 41–91 characters). "Did not blow up on the body
315
+ * measured" is the honest claim — no adversarial search was run for a worse
316
+ * input — and rejecting them is again the direction that never admits an
317
+ * exponential pattern.
318
+ *
224
319
  * The input is guaranteed to compile as a RegExp (checked first by the caller),
225
320
  * so this scan assumes valid, balanced JS regex syntax.
226
321
  * @param {string} src a compiled regex source string
227
- * @returns {boolean} true iff a nested unbounded quantifier is present
322
+ * @returns {boolean} true iff a nested unbounded quantifier, or an unboundedly
323
+ * repeated group with overlapping alternation branches, is present
228
324
  */
229
325
  export function hasNestedQuantifier(src) {
230
- /** @type {{ quant: boolean }[]} */
326
+ /** `quant`: the body repeats unboundedly. `alt`: the body (or a group beneath
327
+ * it) carries overlapping alternation branches. `at`: where the body starts,
328
+ * so the branches can be read back when the group closes. The two hazard flags
329
+ * are tracked separately and propagate separately — merging them would let a
330
+ * plain nested repeat be reported as an alternation overlap and vice versa,
331
+ * and the two are different rewrites for the author. */
332
+ /** @type {{ quant: boolean, alt: boolean, at: number }[]} */
231
333
  const stack = [];
232
334
  for (let i = 0; i < src.length; i++) {
233
335
  const c = src[i];
@@ -254,11 +356,19 @@ export function hasNestedQuantifier(src) {
254
356
  while (i < src.length && src[i] !== ']') { if (src[i] === '\\') i++; i++; }
255
357
  continue;
256
358
  }
257
- if (c === '(') { stack.push({ quant: false }); continue; }
359
+ if (c === '(') { stack.push({ quant: false, alt: false, at: i + groupPrologueLen(src, i) }); continue; }
258
360
  if (c === ')') {
259
361
  const g = stack.pop();
260
362
  const qlen = unboundedQuantLen(src, i + 1);
261
363
  if (g && g.quant && qlen) return true; // group repeats unboundedly AND its body did too
364
+ // …and the SECOND class: the body's own top-level branches overlap, so the
365
+ // engine has more than one way to consume the same text and an unbounded
366
+ // repeat multiplies those ways (MEASURED: `(a|aa)+$` 12.1s on a 35-char
367
+ // body). `g.alt` carries an overlap found in a group BENEATH this one, for
368
+ // the redundant-wrapper reason one comment down.
369
+ const overlap = !!g && (g.alt || altBranchesOverlap(src.slice(g.at, i)));
370
+ if (overlap && qlen) return true;
371
+ if (overlap && stack.length) stack[stack.length - 1].alt = true;
262
372
  // The group is an unbounded-repeated atom of its parent when it is directly
263
373
  // re-quantified (qlen) OR its own body already repeats unboundedly (g.quant):
264
374
  // a redundant wrapper — ((a+))+ , (?:(a+))* , (((\d*)))+ — is the SAME
@@ -276,3 +386,50 @@ export function hasNestedQuantifier(src) {
276
386
  }
277
387
  return false;
278
388
  }
389
+
390
+ /**
391
+ * The catastrophic-backtracking reject, applied to a WHOLE signed document by
392
+ * tree walk — `sweepSecretLiterals`'s twin, and for its reason: some signed
393
+ * documents carry their regexes at a depth no per-field check enumerates.
394
+ *
395
+ * WHY IT EXISTS (PRD v1.60 §1, an OPEN GAP now closed). v1.55 took F49's reject
396
+ * to the operator's own regex fields, and the admissibility sweep that backed it
397
+ * walked `jobs/*.json`. That is not the population of signed specs: a REGISTRY
398
+ * BRIDGE is signed the same way, ships its plans VERBATIM (reuse stores the
399
+ * whole plan, never a template), and lives outside `jobs/`, so a pathological
400
+ * pattern arriving through a bridge was never swept. Nothing had decided
401
+ * bridge-borne patterns were safe — the sweep was written against the directory
402
+ * that held every spec at the time and was never re-aimed.
403
+ *
404
+ * WHAT is rejected is UNCHANGED: `hasNestedQuantifier`, one inventory, shared
405
+ * with job.js and plan.js. Only WHERE it looks is wider. Widening the DETECTOR
406
+ * is arbiter-adjacent and stays parked (F49); widening the POPULATION is this.
407
+ *
408
+ * KEY-SCOPED, deliberately. `pattern` and `gapKeep` are the exactly-three regex
409
+ * fields F49 names (`artifact-written.pattern`, `judged.pattern`, `gapKeep`);
410
+ * every other string in a signed document is prose, a path or a command, and
411
+ * running a regex-shape scan over prose would red goals for containing `(a+)+`
412
+ * in a sentence. A field carrying a regex under a NEW key is not swept until
413
+ * this list names it — stated so the limit is read rather than rediscovered.
414
+ *
415
+ * COMPILE-FIRST, like both per-field callers: the scan's contract is that its
416
+ * input is valid regex syntax, and a string that does not compile is the OTHER
417
+ * validator's red. Two reds on one string bury the real one.
418
+ * @param {any} root
419
+ * @param {(code: string, path: string, detail?: string) => void} red
420
+ */
421
+ export function sweepNestedQuantifiers(root, red) {
422
+ (/** @type {(node: any, at: string, key: string) => void} */
423
+ function sweep(node, at, key) {
424
+ if (typeof node === 'string') {
425
+ if (key !== 'pattern' && key !== 'gapKeep') return;
426
+ try { new RegExp(node, 'm'); } catch { return; }
427
+ if (hasNestedQuantifier(node)) {
428
+ red('invalid-value', at, `catastrophic-backtracking risk: a nested unbounded quantifier (${node}) can hang the untimed evaluator on the MAIN event loop, where the in-process stall fuse cannot fire (F49). Rewrite the pattern — drop the outer repeat, or match once.`);
429
+ }
430
+ return;
431
+ }
432
+ if (Array.isArray(node)) node.forEach((v, i) => sweep(v, `${at}.${i}`, key));
433
+ else if (isObj(node)) for (const [k, v] of Object.entries(node)) sweep(v, at ? `${at}.${k}` : k, k);
434
+ })(root, '', '');
435
+ }
@@ -149,6 +149,31 @@ export function declarationTool(box: {
149
149
  * @param {Record<string, any>} [catalogue]
150
150
  */
151
151
  export function catalogueBlock(catalogue?: Record<string, any>): string;
152
+ /**
153
+ * HOW THE GENRE'S OWN TOOLS PRINT — rendered FROM `genreInstruments`, so the
154
+ * pattern the author is handed and the pattern the tree already grades with are
155
+ * one string.
156
+ *
157
+ * This block is the fix for run msmbpjk6: a parser for a tool WE OWN THE FACTS
158
+ * ABOUT was left to the model to compose, and it composed tsc's terminal-only
159
+ * shape against piped output — 0 matches on 67 real error lines. The delivery is
160
+ * deliberately the MANDATORY-GUARD delivery, not advice: the pattern arrives
161
+ * spelled out, marked genre property, with the real captured line beside it, in
162
+ * the same register as the guard battery that arrives already parameterised.
163
+ *
164
+ * It states the MECHANISM (pipe, not a terminal) rather than only the answer,
165
+ * because the same trap is waiting behind every tool that pretty-prints for a
166
+ * human, and only some of them are in the table yet. That prediction has since
167
+ * been paid off by measurement: `node --test` prints TAP down a pipe and the
168
+ * spec reporter on a terminal, the identical flip, on a second tool.
169
+ *
170
+ * Each row also carries its `capture`, because a count term and a tally term are
171
+ * read by DIFFERENT aggregates and the wrong pairing is a silent misreading, not
172
+ * an error — `first` over a line the tool omits on a good run reports a broken
173
+ * instrument. The block states that mapping once rather than per row.
174
+ * @param {string} lang
175
+ */
176
+ export function instrumentsBlock(lang: string): string;
152
177
  /** The three laws. Stated to the author; enforced, where checkable, by M2. */
153
178
  export function lawsBlock(): string;
154
179
  /**
@@ -321,7 +346,10 @@ export function resolveSourcePrefixes({ workdir, sourcePaths, seedFiles }: {
321
346
  * run could not be read afterwards. A cost entry with no raw beside it is that
322
347
  * run again.
323
348
  */
324
- export function makeCostBook(): {
349
+ /** @param {{ceilingUsd?: number|null}} [o] the OPERATOR's ceiling; `null`/absent is UNBOUNDED */
350
+ export function makeCostBook({ ceilingUsd }?: {
351
+ ceilingUsd?: number | null;
352
+ }): {
325
353
  /** @param {string} label @param {any} r a bare-agent run result @param {number} [attempt] */
326
354
  add(label: string, r: any, attempt?: number): {
327
355
  label: string;
@@ -347,17 +375,28 @@ export function makeCostBook(): {
347
375
  reason: string | null;
348
376
  }[];
349
377
  report(): {
350
- costUsd: number | null;
351
- knownUsd: number;
352
- spendComplete: boolean;
353
- nullCostCalls: number;
354
- unpricedRounds: number;
355
378
  calls: {
356
379
  label: string;
357
380
  costUsd: number | null;
358
381
  unpricedRounds: number;
359
382
  }[];
383
+ costUsd: number | null;
384
+ knownUsd: number;
385
+ spendComplete: boolean;
386
+ nullCostCalls: number;
387
+ unpricedRounds: number;
360
388
  };
389
+ /**
390
+ * THE CEILING'S OWN READING, asked between metered calls. The book is the one
391
+ * place this pipeline's spend accumulates, so it is the one place that can
392
+ * answer honestly — and it answers through the shared predicate rather than a
393
+ * second hand-spelled comparison (`src/text.js`, beside `priceOf`).
394
+ * @returns {'cap-halt'|'pricing-red'|null}
395
+ */
396
+ capStop(): "cap-halt" | "pricing-red" | null;
397
+ /** what was set, stated — `null` is "nobody set one", which is UNBOUNDED and
398
+ * a visible operator choice, never a defaulted number */
399
+ ceilingUsd: number | null;
361
400
  };
362
401
  /**
363
402
  * The default model boundary: one bare-agent Loop per call, with the declaration
@@ -406,13 +445,15 @@ export function makeLoopGenerate(provider: any, { system, maxTokens }?: {
406
445
  *
407
446
  * @param {{workdir: string, seedRef: string, lang: string, verdictType: string,
408
447
  * answers: Record<string|number, string>, questions?: Record<string|number, string>,
409
- * scout: {state: string, facts: any, reason?: string|null, calls?: any[], raws?: any[]},
448
+ * scout: {state: string, facts: any, reason?: string|null, cause?: string|null, calls?: any[], raws?: any[],
449
+ * budgetStop?: 'cap-halt'|'pricing-red'|null},
410
450
  * listing?: {stop: string|null, files: string[]|null, block: string|null, meta: any}|null,
411
451
  * generate: Function, seedReadFn?: Function, closeCtx?: any,
452
+ * ceilingUsd?: number|null,
412
453
  * maxRevisions?: number, structureRetries?: number,
413
454
  * structuredMode?: 'tool'|'text', catalogue?: Record<string, any>}} o
414
455
  */
415
- export function authorClose({ workdir, seedRef, lang, verdictType, answers, questions, scout, listing, generate, seedReadFn, closeCtx, maxRevisions, structureRetries, structuredMode, catalogue, }: {
456
+ export function authorClose({ workdir, seedRef, lang, verdictType, answers, questions, scout, listing, generate, seedReadFn, closeCtx, ceilingUsd, maxRevisions, structureRetries, structuredMode, catalogue, }: {
416
457
  workdir: string;
417
458
  seedRef: string;
418
459
  lang: string;
@@ -423,8 +464,10 @@ export function authorClose({ workdir, seedRef, lang, verdictType, answers, ques
423
464
  state: string;
424
465
  facts: any;
425
466
  reason?: string | null;
467
+ cause?: string | null;
426
468
  calls?: any[];
427
469
  raws?: any[];
470
+ budgetStop?: "cap-halt" | "pricing-red" | null;
428
471
  };
429
472
  listing?: {
430
473
  stop: string | null;
@@ -435,6 +478,7 @@ export function authorClose({ workdir, seedRef, lang, verdictType, answers, ques
435
478
  generate: Function;
436
479
  seedReadFn?: Function;
437
480
  closeCtx?: any;
481
+ ceilingUsd?: number | null;
438
482
  maxRevisions?: number;
439
483
  structureRetries?: number;
440
484
  structuredMode?: "tool" | "text";
@@ -102,6 +102,60 @@ export function genreEnv(lang: string, { sourcePrefixes }?: {
102
102
  * one of them, because ownership is only real if it is mechanical.
103
103
  * @param {string} lang @returns {string[]} */
104
104
  export function genreOwnedEnvNames(lang: string): string[];
105
+ /**
106
+ * HOW THE GENRE'S OWN TOOLS PRINT — genre property, like the suppression battery
107
+ * and like `MYPYPATH`, and for the same reason: it is a FACT ABOUT AN INSTRUMENT
108
+ * WE ALREADY OWN, not a composition choice. Seven hand-written closes read tsc
109
+ * with the identical `/error TS\d+/` (`scripts/u-pulselog-close.mjs:122`,
110
+ * `u-bareguard-close.mjs:123`, `u-bareagent-close.mjs:123`,
111
+ * `u-baremobile-close.mjs:122`, `u-litectx-close.mjs:140`, `types-close.mjs:190`,
112
+ * `types-check.mjs:60`) and one reads mypy with `' error: '`
113
+ * (`u-spawner-close.mjs:133`) — the same provenance the SUPPRESSIONS table has:
114
+ * operator knowledge minted by paying for live runs.
115
+ *
116
+ * WHY IT BECAME GENRE DATA (run msmbpjk6, 2026-08-09, $0.43). Left to the model,
117
+ * the parser for a known tool is a DRAFT-TIME LOTTERY on a fact nobody needed to
118
+ * guess: that run composed tsc's TTY-only pretty shape
119
+ * (`^\S+\.js:\d+:\d+ - error TS\d+:`), which matched 0 of the 67 real error lines
120
+ * the piped run actually printed, while the tool exited 2. The gates held — the
121
+ * stage read as a crashed instrument and signing was refused, correctly — but the
122
+ * refusal cost a whole authoring run to learn something the tree already knew. An
123
+ * earlier run (mslwbkz7) happened to roll the right shape. A coin-flip on an
124
+ * owned fact is not a capability question.
125
+ *
126
+ * WHAT THIS IS NOT: it does not close the parser. A stage counting something we
127
+ * have NOT measured still writes its own pattern, and that remains a real gap —
128
+ * named, not smoothed. This table is where a measured instrument fact lands, one
129
+ * at a time, with its provenance.
130
+ *
131
+ * THE TEST-COUNT FACTS (2026-08-13) landed under exactly that rule, and closed
132
+ * two of the three gaps the paragraph above used to name by hand: the executed
133
+ * count and the failure line, for both runners, are now measured facts rather
134
+ * than draft-time lotteries. Provenance rides on each entry.
135
+ *
136
+ * WHAT STAYED OUT, deliberately, because no evidence covers it:
137
+ * - a pytest FAIL-COUNT figure. A green pytest run omits the segment entirely
138
+ * (`1 passed in 0.04s`), so a figure term over it reports "never reported" on
139
+ * precisely the runs that went well. Failures are counted with the `^FAILED `
140
+ * tally instead, where no match IS zero.
141
+ * - a pytest EXECUTED count. `collected N items` is printed under `-ra` and NOT
142
+ * under `-q` (measured both ways), so no single spelling survives the modes a
143
+ * patient may configure; a stage needing it still composes its own and says so.
144
+ *
145
+ * Returns a fresh copy per call, for the reason `classGuards` does: a frozen
146
+ * constant handed to a caller that edits it is one shared table away from a
147
+ * silently changed ruler.
148
+ * @param {string} lang
149
+ * @returns {{id: string, what: string, lineMatch: string, capture: number|null, example: string, why: string}[]}
150
+ */
151
+ export function genreInstruments(lang: string): {
152
+ id: string;
153
+ what: string;
154
+ lineMatch: string;
155
+ capture: number | null;
156
+ example: string;
157
+ why: string;
158
+ }[];
105
159
  /**
106
160
  * GROUNDING for a RECORDED genre environment — the provenance half of the
107
161
  * owned-env rule, and the reason the by-value comparison is worth anything.
@@ -368,6 +422,42 @@ export const TYPES_GENRE: Readonly<{
368
422
  /** no import-resolution hazard on this runner — an EMPTY list, declared,
369
423
  * never an absent field that would read as "not looked into" (F59) */
370
424
  env: readonly never[];
425
+ instruments: readonly (Readonly<{
426
+ id: "tsc-error-line";
427
+ what: "one type error, as the TypeScript checker prints it";
428
+ lineMatch: "error TS\\d+";
429
+ capture: null;
430
+ example: "src/backup.js(21,19): error TS7006: Parameter 'now' implicitly has an 'any' type.";
431
+ why: string;
432
+ }> | Readonly<{
433
+ id: "node-test-total-count";
434
+ what: "how many tests the runner EXECUTED, off the TAP summary";
435
+ lineMatch: "^# tests (\\d+)$";
436
+ capture: 1;
437
+ example: "# tests 317";
438
+ why: string;
439
+ }> | Readonly<{
440
+ id: "node-test-pass-count";
441
+ what: "how many tests passed, off the TAP summary";
442
+ lineMatch: "^# pass (\\d+)$";
443
+ capture: 1;
444
+ example: "# pass 316";
445
+ why: string;
446
+ }> | Readonly<{
447
+ id: "node-test-fail-count";
448
+ what: "how many tests failed, off the TAP summary";
449
+ lineMatch: "^# fail (\\d+)$";
450
+ capture: 1;
451
+ example: "# fail 1";
452
+ why: string;
453
+ }> | Readonly<{
454
+ id: "node-test-failed-line";
455
+ what: "one failing test, as the TAP reporter names it";
456
+ lineMatch: "^not ok \\d+ ";
457
+ capture: null;
458
+ example: "not ok 7 - edit-renotify: a non-participant edit, and edits on a pending event, ping no one";
459
+ why: string;
460
+ }>)[];
371
461
  }>;
372
462
  python: Readonly<{
373
463
  suppressions: readonly (Readonly<{
@@ -393,6 +483,28 @@ export const TYPES_GENRE: Readonly<{
393
483
  join: ":";
394
484
  why: string;
395
485
  }>[];
486
+ instruments: readonly (Readonly<{
487
+ id: "mypy-error-line";
488
+ what: "one type error, as mypy prints it";
489
+ lineMatch: " error: ";
490
+ capture: null;
491
+ example: "packages/spawner/src/aurora_spawner/timeout_policy.py:89: error: Statement is unreachable [unreachable]";
492
+ why: string;
493
+ }> | Readonly<{
494
+ id: "pytest-pass-count";
495
+ what: "how many tests passed, off pytest's final counts line";
496
+ lineMatch: "(\\d+) passed";
497
+ capture: 1;
498
+ example: "= 1 failed, 2690 passed, 3 skipped, 18 deselected, 1775 warnings in 326.12s (0:05:26) =";
499
+ why: string;
500
+ }> | Readonly<{
501
+ id: "pytest-failed-line";
502
+ what: "one failing test, as pytest's short summary names it";
503
+ lineMatch: "^FAILED ";
504
+ capture: null;
505
+ example: "FAILED packages/soar/tests/test_agent_registry_deprecation.py::TestAgentRegistryDeprecation::test_registry_and_discovery_equivalent_results";
506
+ why: string;
507
+ }>)[];
396
508
  }>;
397
509
  }>;
398
510
  }>;
@@ -81,14 +81,14 @@ export function refuseLockedKind(kind: string): Refusal;
81
81
  * @param {{answers: Record<string|number, any>, repoPath?: string|null, lang: string,
82
82
  * verdictType?: string|null,
83
83
  * questions?: Record<string|number, string>|null, generate?: Function, provider?: any,
84
- * seedRef?: string|null, scout?: any, listing?: any,
84
+ * seedRef?: string|null, scout?: any, listing?: any, ceilingUsd?: number|null,
85
85
  * seedFn?: Function, scoutFn?: Function, listingFn?: Function,
86
86
  * authorFn?: Function, authorOpts?: object}} o
87
87
  * @returns {Promise<{ok: boolean, refusal: Refusal|null, verdictType: string|null,
88
88
  * closeDecl: any, seedRef: string|null, authoring: any, reds: Red[], stop: string|null,
89
89
  * cost: any, interview: any}>}
90
90
  */
91
- export function authorCloseForJob({ answers, repoPath, lang, verdictType, questions, generate, provider, seedRef, scout, listing, seedFn, scoutFn, listingFn, authorFn, authorOpts, }: {
91
+ export function authorCloseForJob({ answers, repoPath, lang, verdictType, questions, generate, provider, seedRef, scout, listing, ceilingUsd, seedFn, scoutFn, listingFn, authorFn, authorOpts, }: {
92
92
  answers: Record<string | number, any>;
93
93
  repoPath?: string | null;
94
94
  lang: string;
@@ -99,6 +99,7 @@ export function authorCloseForJob({ answers, repoPath, lang, verdictType, questi
99
99
  seedRef?: string | null;
100
100
  scout?: any;
101
101
  listing?: any;
102
+ ceilingUsd?: number | null;
102
103
  seedFn?: Function;
103
104
  scoutFn?: Function;
104
105
  listingFn?: Function;
@@ -91,13 +91,14 @@ export function defaultSurveyor({ workdir, granted, auditPath, ctx }: {
91
91
  *
92
92
  * @param {{workdir: string, provider?: any,
93
93
  * rounds?: number, minBytes?: number, blobMax?: number, maxTokens?: number, ctx?: boolean,
94
- * attempts?: number,
94
+ * attempts?: number, ceilingUsd?: number|null,
95
95
  * createLoop?: (o: {system: string, policy: any, onLlmResult: any, provider: any}) => any,
96
96
  * createSurveyor?: (o: {workdir: string, granted: readonly string[], ctx: boolean}) => Promise<any>}} o
97
97
  * @returns {Promise<Survey & {raw: string, raws: ReturnType<typeof scrubRaw>[],
98
- * calls: {label: string, costUsd: number|null, unpricedRounds: number}[]}>}
98
+ * calls: {label: string, costUsd: number|null, unpricedRounds: number}[],
99
+ * budgetStop: 'cap-halt'|'pricing-red'|null}>}
99
100
  */
100
- export function runAuthorScout({ workdir, provider, rounds, minBytes, blobMax, maxTokens, ctx, attempts, createLoop, createSurveyor, }: {
101
+ export function runAuthorScout({ workdir, provider, rounds, minBytes, blobMax, maxTokens, ctx, attempts, ceilingUsd, createLoop, createSurveyor, }: {
101
102
  workdir: string;
102
103
  provider?: any;
103
104
  rounds?: number;
@@ -106,6 +107,7 @@ export function runAuthorScout({ workdir, provider, rounds, minBytes, blobMax, m
106
107
  maxTokens?: number;
107
108
  ctx?: boolean;
108
109
  attempts?: number;
110
+ ceilingUsd?: number | null;
109
111
  createLoop?: (o: {
110
112
  system: string;
111
113
  policy: any;
@@ -125,6 +127,7 @@ export function runAuthorScout({ workdir, provider, rounds, minBytes, blobMax, m
125
127
  costUsd: number | null;
126
128
  unpricedRounds: number;
127
129
  }[];
130
+ budgetStop: "cap-halt" | "pricing-red" | null;
128
131
  }>;
129
132
  /**
130
133
  * `git ls-tree -r --name-only <seed>` — the files as of the SEED COMMIT.
@@ -249,6 +252,16 @@ export const SURVEY_CAUSES: Readonly<{
249
252
  NON_OBJECT: "non-object";
250
253
  /** valid JSON, no content — F59's whole point, and a SEMANTIC failure */
251
254
  EMPTY_OBJECT: "empty-object";
255
+ /**
256
+ * THE SURVEY WAS NEVER ASKED FOR: the operator's money ceiling was already
257
+ * spent when the first attempt came due, so no call was made. Named as its own
258
+ * cause for the reason this enum exists at all — a new route to ABSENT has to
259
+ * be named, and naming it is the moment somebody decides whether it retries.
260
+ * This one does not, and cannot: a retry is precisely what the cap forbade.
261
+ * It is deliberately ABSENT of the model's own doing, and the prose says so —
262
+ * "the scout did not complete" would blame a model that never spoke.
263
+ */
264
+ NOT_FUNDED: "not-funded";
252
265
  }>;
253
266
  /**
254
267
  * THE MALFORMED CLASS — the only causes a retry fires on.
package/types/index.d.ts CHANGED
@@ -12,7 +12,7 @@ export { validateEnvelope, resolveTrySpec, resolveReuse, reuseSpecHash, selectBr
12
12
  export { classifyIncidents, foldLedger, ledgerDeltas, updateLedger, LEDGER_CLASSES } from "./ledger.js";
13
13
  export { workBranchName, prepareWorkBranch, WORK_BRANCH_PREFIX, WORK_BRANCH_RE } from "./workbranch.js";
14
14
  export { runDeclaredClose, runStage, seedRead, seedAtHead, seedListing, changedSet, makeSeedTrees, LIVE_KINDS, STOP_FAULTS, JUDGED_MARKER, EXIT_GREEN, EXIT_RED, EXIT_STOP } from "./kinds.js";
15
- export { validateDeclaration, normalizeDeclaration, KIND_CATALOGUE, CATALOGUE_KINDS, CATALOGUE_LIVE_KINDS, LOCKED_KINDS, TYPES_GENRE, TYPES_GENRE_TEMPLATE, GENRE_LANGUAGES, genreEnv, genreOwnedEnvNames, DIRECTIONS, BASELINES, MAX_STAGES, VERDICT_CLASSES, LOCKED_CLASSES, LIVE_CLASSES, CLASS_BATTERIES, classGuards, closeCeiling } from "./authoring.js";
15
+ export { validateDeclaration, normalizeDeclaration, KIND_CATALOGUE, CATALOGUE_KINDS, CATALOGUE_LIVE_KINDS, LOCKED_KINDS, TYPES_GENRE, TYPES_GENRE_TEMPLATE, GENRE_LANGUAGES, genreEnv, genreOwnedEnvNames, genreInstruments, DIRECTIONS, BASELINES, MAX_STAGES, VERDICT_CLASSES, LOCKED_CLASSES, LIVE_CLASSES, CLASS_BATTERIES, classGuards, closeCeiling } from "./authoring.js";
16
16
  export { runAuthorScout, buildSeedListing, seedFileList, classifySurvey, AUTHOR_SCOUT_VERBS, SCOUT_ATTEMPTS, SURVEY_CAUSES, SCOUT_RETRY_CAUSES } from "./authorscout.js";
17
17
  export { scrubRaw, RAW_PERSIST_MAX, RAW_TRIM_MARKER } from "./text.js";
18
18
  export { authorClose, authorPrompt, declarationSchema, makeLoopGenerate, MAX_REVISIONS, QUESTION_SETS, GREEN_QUESTIONS, CLASS_STATEMENTS, questionsFor, requiredAnswersFor } from "./authorflow.js";
package/types/kinds.d.ts CHANGED
@@ -36,15 +36,25 @@ export function seedListing(workdir: string, seedRef: string): Promise<{
36
36
  files: string[];
37
37
  }>;
38
38
  /**
39
- * The two arbiter books, and the shape of each exclusion is the whole point.
39
+ * The arbiter books, and the shape of each exclusion is the whole point.
40
40
  *
41
- * `.litectx/` is a PREFIX — everything beneath the store belongs to the
42
- * arbiter. `gate-audit.jsonl` is an EXACT repo-relative path and never a
43
- * basename or a pattern: the gate audit is written to
41
+ * `.litectx/` and `.smoke/` are PREFIXES — everything beneath either store
42
+ * belongs to the arbiter. `gate-audit.jsonl` is an EXACT repo-relative path and
43
+ * never a basename or a pattern: the gate audit is written to
44
44
  * `join(workdir, 'gate-audit.jsonl')` and nowhere else, so a worker-authored
45
45
  * `src/gate-audit.jsonl` is NOT an arbiter book and must still count as work.
46
46
  * Matching by basename would swallow it — and swallowing a worker's real
47
- * write is the failure direction that reads as a clean tree.
47
+ * write is the failure direction that reads as a clean tree. `src/smoke.js` and
48
+ * `tests/smoke.test.js` are that same decoy for the store below.
49
+ *
50
+ * `.smoke/` — F98's parked residual, closed here. The fence has always denied
51
+ * `join(workdir, '.smoke')` on every worker (src/planrun.js, src/authorscout.js)
52
+ * and the persona now states the rule; this reader, which decides what COUNTS AS
53
+ * WORK, was the one place that never knew the name. On patients whose .gitignore
54
+ * default-denies dot-directories the store never reaches `ls-files --others` and
55
+ * the gap is masked — by the PATIENT's config, not by anything here. Incidental
56
+ * masking is not an exclusion: on a patient without that line the arbiter's own
57
+ * store would enter the changed set as the worker's writing.
48
58
  * @param {string} rel a repo-relative POSIX path
49
59
  * @returns {string|null} the book it belongs to, or null
50
60
  */
@@ -146,13 +156,36 @@ export function normalizeParser(parser: any, at?: string): {
146
156
  * that matched BEFORE the filter judged them — kept, unattributable and dropped
147
157
  * alike — and it is the only evidence of liveness a caller has once the scope
148
158
  * has removed the whole population. It is never the number being read.
159
+ *
160
+ * `matched` is the KEPT lines themselves, PER TERM and index-aligned with
161
+ * `breakdown`: entry i holds, in output order, exactly the lines term i's
162
+ * subtotal was computed FROM. Run u-msn227nq paid for the echo — the executor
163
+ * counted 8 real `error TS…` lines and told the worker "8 match(es)", discarding
164
+ * every address, and a worker with a number and no file to open went looking for
165
+ * the arbiter's own books instead.
166
+ *
167
+ * TWO EXCLUSIONS, both of them "this line is not what the count is made of":
168
+ * - a scope-DROPPED line is never in here. It was never counted, so aiming the
169
+ * worker at it would aim it outside its own population.
170
+ * - under `first`, only the line the aggregate actually READ. The later matches
171
+ * are real matches (`breakdown[i].matches` still counts them) but the value
172
+ * never contained them, and echoing them points the worker at a number that
173
+ * is not in the reading.
174
+ *
175
+ * And the GROUPING is the third: a term's sign lives in `breakdown[i]`, so lines
176
+ * pooled flat across terms lose the one fact that says what they DID to the
177
+ * number. A subtracted term's line rendered beside an added one reads as a wall
178
+ * to go fix, and fixing it moves the count the wrong way. Deduplication is
179
+ * therefore within a term only — one line matched by two terms plays two
180
+ * arithmetic roles and is named under each. The count is computed from `values`,
181
+ * never from this list.
149
182
  * @param {string} output @param {any[]} terms normalised terms
150
183
  * Every stop here is `crashed` in the arbiter's vocabulary — the command RAN,
151
184
  * came back, and the number could not be read out of it. Nothing in this
152
185
  * function can observe a spawn failure, a timeout or a signal, so it never
153
186
  * claims one.
154
187
  * @param {{scope?: any, workdir?: string}} [o]
155
- * @returns {{stop: string, fault: string, notes?: string[]}|{stop: null, value: number, breakdown: any[], notes: string[], preScopeMatches: number}}
188
+ * @returns {{stop: string, fault: string, notes?: string[]}|{stop: null, value: number, breakdown: any[], notes: string[], preScopeMatches: number, matched: string[][]}}
156
189
  */
157
190
  export function parseValue(output: string, terms: any[], { scope, workdir }?: {
158
191
  scope?: any;
@@ -167,6 +200,7 @@ export function parseValue(output: string, terms: any[], { scope, workdir }?: {
167
200
  breakdown: any[];
168
201
  notes: string[];
169
202
  preScopeMatches: number;
203
+ matched: string[][];
170
204
  };
171
205
  /**
172
206
  * A caller-scoped holder for detached seed worktrees. Scoped rather than
@@ -296,6 +330,24 @@ export const SIGNS: readonly number[];
296
330
  * counted demand (the request-red path), and reaching one at runtime is a
297
331
  * stop — never a red, and never a silent pass. */
298
332
  export const LIVE_KINDS: readonly string[];
333
+ /**
334
+ * The arbiter's book NAMES, one home. F98's mechanism was exactly this list
335
+ * living as independent literals: the fence deny arrays (src/planrun.js,
336
+ * src/authorscout.js) knew `.smoke` while `isArbiterBook` — the reader that
337
+ * decides what COUNTS AS WORK — did not, and the drift was masked by the
338
+ * patient's own .gitignore. Every consumer (the fence deny lists, this file's
339
+ * reader, the persona's stated law, run.js's smoke-store root) now spells the
340
+ * names from here, so adding or renaming a book is one edit that cannot leave
341
+ * a site behind.
342
+ *
343
+ * `ARBITER_BOOK_STORES` are directory PREFIXES; `GATE_AUDIT_FILE` is an EXACT
344
+ * repo-relative path (see `isArbiterBook`'s docstring for why never a
345
+ * basename). Order is the fence deny-list order and the persona's prose order.
346
+ */
347
+ export const SMOKE_STORE: ".smoke";
348
+ export const LITECTX_STORE: ".litectx";
349
+ export const GATE_AUDIT_FILE: "gate-audit.jsonl";
350
+ export const ARBITER_BOOK_STORES: readonly string[];
299
351
  export type Red = {
300
352
  code: string;
301
353
  path: string;