bareloop 0.10.0 → 0.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bareloop",
3
- "version": "0.10.0",
3
+ "version": "0.12.0",
4
4
  "description": "Workflows that earn their own design, with receipts — agent-authored workflows for repeated, long, verifiable jobs that improve across runs under an un-gameable gate.",
5
5
  "type": "module",
6
6
  "main": "./src/index.js",
@@ -23,8 +23,8 @@
23
23
  "prepublishOnly": "npm run build:types"
24
24
  },
25
25
  "dependencies": {
26
- "bare-agent": "^0.35.0",
27
- "bareguard": "^0.12.0",
26
+ "bare-agent": "^0.36.0",
27
+ "bareguard": "^0.13.0",
28
28
  "litectx": "^0.32.0"
29
29
  },
30
30
  "devDependencies": {
package/src/authorflow.js CHANGED
@@ -75,11 +75,12 @@
75
75
  import { createRequire } from 'node:module';
76
76
  import { realpathSync } from 'node:fs';
77
77
  import { join, relative, isAbsolute } from 'node:path';
78
- import { seedRead as runSeedReadStages, makeSeedTrees, AGGREGATES, SIGNS, MAX_TERMS } from './kinds.js';
78
+ import { seedRead as runSeedReadStages, makeSeedTrees, AGGREGATES, SIGNS, MAX_TERMS, MAX_JUDGED_PATHS } from './kinds.js';
79
+ import { JUDGE_RULE_IDS } from './judged.js';
79
80
  import {
80
81
  KIND_CATALOGUE, MAX_STAGES, DIRECTIONS, BASELINES,
81
82
  GENRE_LANGUAGES, TYPES_GENRE_TEMPLATE, classGuards, genreEnv, genreOwnedEnvNames, genreInstruments,
82
- validateDeclaration, envCapableKind, VERDICT_CLASSES, LIVE_CLASSES,
83
+ validateDeclaration, envCapableKind, classMenu, VERDICT_CLASSES, LIVE_CLASSES,
83
84
  } from './authoring.js';
84
85
  import { buildSeedListing, cleanEntry, SURVEY_CAUSES } from './authorscout.js';
85
86
  import { extractArtifact, priceOf, scrubRaw, tallyCalls, capStop } from './text.js';
@@ -163,29 +164,107 @@ export const STRUCTURE_INSTRUCTION_TEXT = 'Your reply could not be read as one J
163
164
  * cannot emit anything else (D2/D3), so a genre it has never seen is a
164
165
  * composition problem, not a catalogue problem.
165
166
  *
166
- * The GREEN set is questions 1–6 of the frozen TYPES set (prereg §5) BYTE FOR
167
- * BYTE. What is gone is the seventh slot — D13's genre CONFIRM ("this looks like
168
- * a type-fixing job, correct?") — superseded in full: the interview never asks
169
- * about a genre again. The refusal it carried did not disappear; it MOVED to the
170
- * composer, on the same counted `request-red` path (see `authorCloseForJob`).
171
- * The remaining six are already genre-neutral, which is why generalising them
172
- * required deleting a slot rather than rewording one.
167
+ * The GREEN set descends from questions 1–6 of the frozen TYPES set (prereg §5).
168
+ * TWO slots have been deleted since, both because the interview was ASKING FOR
169
+ * SOMETHING IT ALREADY HAD or already knew:
170
+ *
171
+ * - D13's genre CONFIRM ("this looks like a type-fixing job, correct?") —
172
+ * superseded in full: the interview never asks about a genre again, and the
173
+ * refusal it carried MOVED to the composer, on the same counted
174
+ * `request-red` path (see `authorCloseForJob`);
175
+ * - "Is there a code repo I can look at? Where?" — DROPPED on hamr's ruling
176
+ * after he drove the terminal interview himself (2026-08-15, verbatim: *"drop
177
+ * Q6"*). The repository is a MANDATORY, STRUCTURED input already —
178
+ * `runInterview`'s own `repoPath`, which the runner takes from `--patient` and
179
+ * refuses to start without. Asking a person to re-type in prose a path the
180
+ * machine is holding in a variable is the SWE tax this product refuses, and
181
+ * it invites a second, drifting answer for the same fact (hamr's own answer
182
+ * on the live run was *"Yes — the patient itself."*). Nothing downstream lost
183
+ * anything: the scout reads `repoPath`, never the answer.
184
+ *
185
+ * Question 2 was WIDENED in the same ruling: it now asks for the read-or-draw-from
186
+ * files as well as the ones that change. The two halves belong in one answer
187
+ * because they are one decision for the person answering — what the work touches,
188
+ * and what it touches only to look at.
189
+ *
190
+ * The five that remain are genre-neutral, which is why every generalisation so far
191
+ * has deleted a slot rather than reworded one. They are NUMBERED CONTIGUOUSLY from
192
+ * 1: the number a person sees is the key their answer is filed under, and a gap
193
+ * would be a hole in a form nobody can explain.
173
194
  */
174
195
  export const GREEN_QUESTIONS = Object.freeze({
175
196
  1: 'What do you want done?',
176
- 2: 'Which files or folders should change?',
197
+ 2: 'Which files or folders should change? And which files should the work read or draw from (they stay untouched)?',
177
198
  3: 'Is there anything that must not change?',
178
199
  4: 'How do you check today whether it\'s working?',
179
200
  5: 'What would make you say this came back worse than before?',
180
- 6: 'Is there a code repo I can look at? Where?',
181
201
  });
182
202
 
183
203
  /**
184
- * The three sets. `questions: null` on a locked class is ABSENT, never `{}`:
185
- * an empty set would read as "this class needs no questions", which is a
186
- * different and false statement (F59's distinction, at the interview layer).
187
- * Nothing ever reads them — a locked class refuses at ADMISSION, before its
188
- * questions run — and the two readers below throw rather than hand back nothing.
204
+ * THE HITL SET (N4 slice 1) — the green questions BYTE FOR BYTE, plus one.
205
+ *
206
+ * The five are unchanged deliberately rather than economically: the
207
+ * mechanical-first composition law (2026-08-07) says every hitl close is
208
+ * *deterministic stages first, judge minimal, human last*, so a hitl job needs
209
+ * exactly the same mechanical facts a green one does — what changes, what must
210
+ * not, how it is checked today, what "worse" looks like. Rewording them would
211
+ * fork one interview into two that drift.
212
+ *
213
+ * The last one is the only thing the green set cannot supply, and it is not a
214
+ * flourish: `human-confirms` requires an `ask` — the question the signer answers
215
+ * at the end of the run — and nothing else in the interview names it. Without it
216
+ * the composer would have to invent what a person is deciding, which is the one
217
+ * thing a human stage must never have done for it. It is the SPREAD's next number
218
+ * rather than a fixed one, so a green-side deletion renumbers it instead of
219
+ * leaving a gap (it was 7 while the green set held six, and is 6 now it holds five).
220
+ */
221
+ export const HITL_QUESTIONS = Object.freeze({
222
+ ...GREEN_QUESTIONS,
223
+ 6: 'When you look at the finished result yourself, what are you deciding?',
224
+ });
225
+
226
+ /**
227
+ * THE SOFTGREEN SET (module 3, design record §4.6) — the green questions BYTE
228
+ * FOR BYTE, plus TWO.
229
+ *
230
+ * Same discipline as the hitl set, and for the same reason: a softgreen close is
231
+ * *mechanical stages first, the judged stage after* (§4.5), so a softgreen job
232
+ * needs every mechanical fact a green one does. No renumbering of the green set,
233
+ * no genre question, nothing reworded.
234
+ *
235
+ * The two additions are the only things the green set cannot supply, and neither
236
+ * is a flourish — they are the judged floor's two REQUIRED inputs:
237
+ * - Q6 becomes the RUBRIC CARD (§4.3): the person's own pass/fail lines,
238
+ * compiled into enumerated items, signed, and enumerated in the spec hash.
239
+ * The documented ceiling rides with it — the judge catches violations of
240
+ * STATED card items only, and the fix for a miss is a new card line and a
241
+ * re-sign, never a smarter judge.
242
+ * - Q7 becomes the frozen CALIBRATION SET (§4.4): the whole pipe must grade it
243
+ * correctly before the close is signable.
244
+ * Both are asked in the person's own terms — an example they would pass and one
245
+ * they would fail, not a rubric they are asked to write — because compiling a lay
246
+ * answer into extractable items is the LLM's job (D5, proposed-and-signed) and
247
+ * asking the person to do it is the SWE tax this product refuses.
248
+ *
249
+ * What the two answers COMPILE INTO is module 4. This module carries them
250
+ * exactly as the hitl set carries its own last answer: they ride in `answers`,
251
+ * they reach the composer verbatim through the prompt's interview block, and
252
+ * nothing here derives anything from them.
253
+ */
254
+ export const SOFTGREEN_QUESTIONS = Object.freeze({
255
+ ...GREEN_QUESTIONS,
256
+ 6: 'When you judge the result yourself, what separates a pass from a fail? Name the few things you actually look for.',
257
+ 7: 'Give one example you\'d pass and one you\'d fail, and say why.',
258
+ });
259
+
260
+ /**
261
+ * The three sets, all LIVE as of softgreen module 3. The locked shape stays
262
+ * spelled out in the readers below rather than deleted with the last locked
263
+ * class: `questions: null` is ABSENT, never `{}` — an empty set would read as
264
+ * "this class needs no questions", a different and false statement (F59's
265
+ * distinction, at the interview layer) — and a locked class refuses at ADMISSION,
266
+ * before its questions run, so the two readers throw rather than hand back
267
+ * nothing. A fourth class arrives locked, and the machinery is here for it.
189
268
  * @type {Record<string, {locked: boolean, questions: Record<string|number, string>|null, required: readonly number[]|null}>}
190
269
  */
191
270
  export const QUESTION_SETS = Object.freeze({
@@ -194,8 +273,16 @@ export const QUESTION_SETS = Object.freeze({
194
273
  questions: GREEN_QUESTIONS,
195
274
  required: Object.freeze(Object.keys(GREEN_QUESTIONS).map(Number).sort((a, b) => a - b)),
196
275
  }),
197
- 'soft-green': Object.freeze({ locked: true, questions: null, required: null }),
198
- hitl: Object.freeze({ locked: true, questions: null, required: null }),
276
+ 'soft-green': Object.freeze({
277
+ locked: false,
278
+ questions: SOFTGREEN_QUESTIONS,
279
+ required: Object.freeze(Object.keys(SOFTGREEN_QUESTIONS).map(Number).sort((a, b) => a - b)),
280
+ }),
281
+ hitl: Object.freeze({
282
+ locked: false,
283
+ questions: HITL_QUESTIONS,
284
+ required: Object.freeze(Object.keys(HITL_QUESTIONS).map(Number).sort((a, b) => a - b)),
285
+ }),
199
286
  });
200
287
 
201
288
  /**
@@ -213,8 +300,27 @@ export const CLASS_STATEMENTS = Object.freeze({
213
300
  + 'to decide it — no judgement, no score, and no person in the loop. Every stage you compose is a mechanical '
214
301
  + 'measurement of this repository. If part of what they asked for cannot be measured that way, leave it out and '
215
302
  + 'say so in your notes rather than approximating it with a stage that means something else.',
216
- 'soft-green': null,
217
- hitl: null,
303
+ // softgreen module 3. Stated as an ORDER for the same reason hitl's is: the
304
+ // close is first-red-wins, so the cheap mechanical stages shield the one stage
305
+ // that spends money, and the judge is never the first filter. The last sentence
306
+ // is the documented ceiling (§4.3) said to the composer in its own terms — the
307
+ // judge only ever checks lines the card names, so an unstated expectation is
308
+ // not a stricter judge, it is a line nobody wrote.
309
+ 'soft-green': 'The person declared this job SOFT-GREEN: part of what "done" means here needs JUDGEMENT — reading '
310
+ + 'the result and deciding whether it is any good — and no command can render it. Compose the mechanical part '
311
+ + 'first: everything about this repository a command can decide is still a mechanical stage, exactly as it would '
312
+ + 'be for a green job. Then put ONE judged-floor stage AFTER them, carrying the few things the person said they '
313
+ + 'actually look for and the real files the work lands in. A judged stage decides only the lines its card names, '
314
+ + 'so name each thing they look for as its own line, and leave nothing to be inferred.',
315
+ // N4 slice 1. The mechanical-first composition law is stated as an ORDER
316
+ // because first-red-wins makes the order the mechanism: the cheap stages shield
317
+ // the expensive one, and the person is never the first filter.
318
+ hitl: 'The person declared this job HITL: a PERSON renders the final verdict, because their judgement is what '
319
+ + '"done" means here. Compose the mechanical part first — everything about this repository a command can decide '
320
+ + 'is still a mechanical stage, exactly as it would be for a green job — and put ONE human-confirms stage LAST, '
321
+ + 'carrying the question they answer when they look at the result. Never make the person the first filter: a '
322
+ + 'stage a command can render is never handed to them, and they are asked only once, at the end, about the part '
323
+ + 'no command can hold.',
218
324
  });
219
325
 
220
326
  /** @param {string} verdictType @returns {{locked: boolean, questions: any, required: any}} */
@@ -354,6 +460,45 @@ export const PARAM_SCHEMAS = Object.freeze({
354
460
  extensions: { type: 'array', minItems: 1, items: { type: 'string' } },
355
461
  allowPrefixes: { type: 'array', minItems: 1, items: { type: 'string' } },
356
462
  requireNonEmpty: { const: true },
463
+ // SOFTGREEN's two. `rule` is an ENUM off the judge's own rulebook — where only a
464
+ // closed set can satisfy a field, the illegal value is made inexpressible rather
465
+ // than rejected after the fact (`check-passes(name)`, again) — and `text` is the
466
+ // only free string, carrying the signer's words so a red can be explained in them.
467
+ card: {
468
+ type: 'object',
469
+ description: 'the rubric, as enumerated items: one line per thing the person actually looks for, in the order '
470
+ + 'they look for them. The rule is SELECTED from the arbiter\'s own rulebook; the text is theirs.',
471
+ properties: {
472
+ items: {
473
+ type: 'array',
474
+ minItems: 1,
475
+ items: {
476
+ type: 'object',
477
+ properties: {
478
+ rule: { enum: [...JUDGE_RULE_IDS], description: 'which owned rule this line asserts' },
479
+ text: { type: 'string', minLength: 1, description: 'what this line means, in the person\'s own words' },
480
+ },
481
+ required: ['rule', 'text'],
482
+ additionalProperties: false,
483
+ },
484
+ },
485
+ },
486
+ required: ['items'],
487
+ additionalProperties: false,
488
+ },
489
+ paths: {
490
+ type: 'array',
491
+ minItems: 1,
492
+ maxItems: MAX_JUDGED_PATHS,
493
+ items: { type: 'string' },
494
+ description: 'the AUTHORITATIVE artifacts to judge, repository-relative, read off the listing. One paid call each.',
495
+ },
496
+ ask: {
497
+ type: 'string',
498
+ minLength: 1,
499
+ description: 'the plain question the person answers while looking at the finished result. They see it verbatim, '
500
+ + 'so write what they are DECIDING — never a bare "approve?".',
501
+ },
357
502
  });
358
503
 
359
504
  /**
@@ -391,19 +536,27 @@ export function schemaCoverage(catalogue = KIND_CATALOGUE) {
391
536
  * and they arrive as validation reds through the revise loop — a schema that
392
537
  * tried to restate them would be a second validator.
393
538
  *
539
+ * `verdictType` narrows the branches to the kinds the picked class may express
540
+ * (softgreen module 3, `classMenu`): a green job cannot SPELL `judged-floor`,
541
+ * rather than spelling it and being told afterwards. COVERAGE is still computed
542
+ * over the WHOLE catalogue — the class filter must never make a parameter look
543
+ * orphaned and turn a menu narrowing into a catalogue-drift alarm.
544
+ *
394
545
  * THROWS on a catalogue it cannot cover: a malformed catalogue is a bareloop bug,
395
546
  * and failing loudly beats emitting a schema that quietly cannot say something
396
547
  * the validator will then demand.
397
548
  * @param {Record<string, any>} [catalogue]
549
+ * @param {{verdictType?: string|null}} [o]
398
550
  */
399
- export function declarationSchema(catalogue = KIND_CATALOGUE) {
551
+ export function declarationSchema(catalogue = KIND_CATALOGUE, { verdictType = null } = {}) {
400
552
  const cov = schemaCoverage(catalogue);
401
553
  if (!cov.ok) {
402
554
  throw new Error('[authorflow] the declaration schema does not cover the catalogue — '
403
555
  + `missing: ${cov.missing.join(', ') || 'none'}; orphaned: ${cov.orphan.join(', ') || 'none'}`);
404
556
  }
557
+ const menu = classMenu(verdictType, catalogue);
405
558
  const branches = Object.entries(catalogue)
406
- .filter(([, spec]) => !spec.locked)
559
+ .filter(([kind, spec]) => !spec.locked && menu.includes(kind))
407
560
  .map(([kind, spec]) => {
408
561
  /** @type {Record<string, any>} */
409
562
  const properties = {};
@@ -451,13 +604,13 @@ export function declarationSchema(catalogue = KIND_CATALOGUE) {
451
604
  * The output channel. It records and acknowledges; it takes no action, which is
452
605
  * why handing it to a toolless authoring call does not reopen D3.
453
606
  * @param {{calls: any[]}} box
454
- * @param {{catalogue?: Record<string, any>}} [o]
607
+ * @param {{catalogue?: Record<string, any>, verdictType?: string|null}} [o]
455
608
  */
456
- export function declarationTool(box, { catalogue = KIND_CATALOGUE } = {}) {
609
+ export function declarationTool(box, { catalogue = KIND_CATALOGUE, verdictType = null } = {}) {
457
610
  return {
458
611
  name: DECLARATION_TOOL_NAME,
459
612
  description: 'Deliver the finished close. Call this exactly once, with the whole declaration as its arguments.',
460
- parameters: declarationSchema(catalogue),
613
+ parameters: declarationSchema(catalogue, { verdictType }),
461
614
  execute: async (/** @type {any} */ args) => { box.calls.push(args); return DECLARATION_ACK; },
462
615
  };
463
616
  }
@@ -468,10 +621,19 @@ export function declarationTool(box, { catalogue = KIND_CATALOGUE } = {}) {
468
621
  * The catalogue, as the authoring call sees it — rendered FROM the catalogue, so
469
622
  * the prompt and the validator judge one set of facts. `shape` and `asserts` are
470
623
  * M2's own strings.
624
+ *
625
+ * `verdictType` narrows it to the picked class's menu, exactly as it narrows the
626
+ * schema — the prompt and the tool must offer ONE set of kinds, or the composer
627
+ * reads about a kind it cannot call. A kind above the ceiling is omitted rather
628
+ * than rendered as unavailable: a LOCKED row says "this does not run in v1",
629
+ * which is a fact about the build; "not for your class" is a fact about a choice
630
+ * the person already made, and printing it invites the composer to argue with it.
471
631
  * @param {Record<string, any>} [catalogue]
632
+ * @param {{verdictType?: string|null}} [o]
472
633
  */
473
- export function catalogueBlock(catalogue = KIND_CATALOGUE) {
474
- const rows = Object.entries(catalogue).map(([name, spec]) => (spec.locked
634
+ export function catalogueBlock(catalogue = KIND_CATALOGUE, { verdictType = null } = {}) {
635
+ const menu = classMenu(verdictType, catalogue);
636
+ const rows = Object.entries(catalogue).filter(([name]) => menu.includes(name)).map(([name, spec]) => (spec.locked
475
637
  ? `- ${name} — LOCKED. Not available in v1; it will not run.`
476
638
  : `- ${name}\n ${spec.shape}\n required: ${spec.required.join(', ')}`
477
639
  + `${spec.optional.length ? ` optional: ${spec.optional.join(', ')}` : ''}\n asserts: ${spec.asserts}`));
@@ -659,7 +821,9 @@ measure anything.`;
659
821
  `THE INTERVIEW — the person's own words\n\n${interview}`,
660
822
  `THE FACTS OBJECT — from a read-only survey of the repository\n\n${JSON.stringify(facts, null, 2)}`
661
823
  + (listingBlock ? `\n\n${listingBlock}` : ''),
662
- catalogueBlock(catalogue),
824
+ // CLASS-SCOPED (softgreen module 3): the composer for a green job is never
825
+ // shown a kind whose pick would red as `class-ceiling` after the call.
826
+ catalogueBlock(catalogue, { verdictType }),
663
827
  lawsBlock(),
664
828
  `THE GENRE TEMPLATE — fixed by the job's genre (${lang}), not yours to decide\n\n`
665
829
  + 'This job\'s genre carries a fixed close SHAPE. The following policy is law for your declaration. It names\n'
@@ -912,8 +1076,19 @@ export function resolveSourcePrefixes({ workdir, sourcePaths, seedFiles }) {
912
1076
  * run could not be read afterwards. A cost entry with no raw beside it is that
913
1077
  * run again.
914
1078
  */
915
- /** @param {{ceilingUsd?: number|null}} [o] the OPERATOR's ceiling; `null`/absent is UNBOUNDED */
916
- export function makeCostBook({ ceilingUsd = null } = {}) {
1079
+ /**
1080
+ * @param {{ceilingUsd?: number|null,
1081
+ * onCall?: (call: {label: string, costUsd: number|null, unpricedRounds: number}) => void}} [o]
1082
+ * `ceilingUsd` is the OPERATOR's ceiling; `null`/absent is UNBOUNDED.
1083
+ * `onCall` is a REPORTING seam fired as each metered call lands — the shell's
1084
+ * only chance to know what has been spent while the run is still alive. It is
1085
+ * never consulted and never governs: `capStop` below is still the one
1086
+ * predicate, still asked between calls, and still reads the same `entries`.
1087
+ * `absorb` deliberately does NOT fire it: those calls were metered (and
1088
+ * reported) by whoever made them, and reporting them twice would double the
1089
+ * scout's spend in every reader downstream.
1090
+ */
1091
+ export function makeCostBook({ ceilingUsd = null, onCall = () => {} } = {}) {
917
1092
  /** @type {{label: string, costUsd: number|null, unpricedRounds: number}[]} */
918
1093
  const entries = [];
919
1094
  /** @type {ReturnType<typeof scrubRaw>[]} */
@@ -921,8 +1096,10 @@ export function makeCostBook({ ceilingUsd = null } = {}) {
921
1096
  return {
922
1097
  /** @param {string} label @param {any} r a bare-agent run result @param {number} [attempt] */
923
1098
  add(label, r, attempt = 1) {
924
- entries.push({ label, ...priceOf(r) });
1099
+ const call = { label, ...priceOf(r) };
1100
+ entries.push(call);
925
1101
  raws.push(scrubRaw({ label, attempt, text: r?.text, reason: r?.error ? String(r.error) : null }));
1102
+ onCall({ ...call });
926
1103
  return entries.at(-1);
927
1104
  },
928
1105
  /** @param {{label: string, costUsd: number|null, unpricedRounds: number}[]} calls
@@ -985,13 +1162,23 @@ function canonical(x) {
985
1162
 
986
1163
  /**
987
1164
  * ONE structured ask, with the malformed-emission retry bounded on its OWN axis.
988
- * A reply that carries no declaration is an ARTIFACT-RED — name the axis, retry
1165
+ * A reply that carries no artifact is an ARTIFACT-RED — name the axis, retry
989
1166
  * under a cap, write nothing — and it never consumes a revision, because a
990
1167
  * transport fault is not a defect in a close.
1168
+ *
1169
+ * THE CHANNEL IS A PARAMETER, and that is softgreen module 4's only change here:
1170
+ * the rubric card and the calibration set are a SECOND schema-forced emission
1171
+ * with the same failure modes, and a second ladder is a second place for the cap,
1172
+ * the reject block and the re-ask to drift. The declaration channel below is the
1173
+ * one this loop has always used, spelled out rather than inlined — nothing about
1174
+ * the authoring path's behaviour moves.
991
1175
  * @param {{messages: any[], generate: Function, mode: 'tool'|'text', retries: number,
992
- * label: string, book: ReturnType<typeof makeCostBook>, catalogue: Record<string, any>}} o
1176
+ * label: string, book: ReturnType<typeof makeCostBook>,
1177
+ * channel: {name: string, instruction: string, textPath?: string, tool: (box: {calls: any[]}) => any}}} o
1178
+ * @returns {Promise<{artifact: any, attempts: number, convo: any[], raw: string,
1179
+ * providerError: string|null, red: Red|null, budget: 'cap-halt'|'pricing-red'|null}>}
993
1180
  */
994
- async function askDeclaration({ messages, generate, mode, retries, label, book, catalogue }) {
1181
+ export async function askStructured({ messages, generate, mode, retries, label, book, channel }) {
995
1182
  /** @type {any[]} */
996
1183
  const convo = [...messages];
997
1184
  let attempts = 0;
@@ -1007,49 +1194,78 @@ async function askDeclaration({ messages, generate, mode, retries, label, book,
1007
1194
  // unbounded. `attempts` is returned as-is so the caller can tell a cap that
1008
1195
  // tripped before this ask began (0) from one that cut a retry ladder short.
1009
1196
  const halt = book.capStop();
1010
- if (halt) return { declaration: null, attempts, convo, raw: '', providerError: null, red: null, budget: halt };
1197
+ if (halt) return { artifact: null, attempts, convo, raw: '', providerError: null, red: null, budget: halt };
1011
1198
 
1012
1199
  /** @type {{calls: any[]}} */
1013
1200
  const box = { calls: [] };
1014
- const tools = mode === 'tool' ? [declarationTool(box, { catalogue })] : [];
1201
+ // the tool's schema is CLASS-SCOPED, like the prompt's catalogue block: the
1202
+ // two must offer one set of kinds, or the composer reads about a kind it
1203
+ // cannot call — or worse, calls one the ceiling will refuse after we paid.
1204
+ const tools = mode === 'tool' ? [channel.tool(box)] : [];
1015
1205
  const r = await generate(convo, tools, {});
1016
1206
  attempts += 1;
1017
1207
  book.add(attempt === 0 ? label : `${label}#${attempt + 1}`, r, attempts);
1018
1208
  const raw = redactSecrets(String(r?.text ?? ''));
1019
1209
 
1020
1210
  if (r?.error) {
1021
- return { declaration: null, attempts, convo, raw, providerError: String(r.error), red: null, budget: null };
1211
+ return { artifact: null, attempts, convo, raw, providerError: String(r.error), red: null, budget: null };
1022
1212
  }
1023
1213
 
1024
1214
  if (mode === 'tool') {
1025
- if (box.calls.length === 1) return { declaration: box.calls[0], attempts, convo, raw, providerError: null, red: null, budget: null };
1215
+ if (box.calls.length === 1) return { artifact: box.calls[0], attempts, convo, raw, providerError: null, red: null, budget: null };
1026
1216
  red = {
1027
1217
  code: 'artifact-red',
1028
- path: DECLARATION_TOOL_NAME,
1218
+ path: channel.name,
1029
1219
  detail: box.calls.length === 0
1030
- ? `the reply delivered no ${DECLARATION_TOOL_NAME} call — structure is enforced, prose is never parsed`
1031
- : `the reply delivered ${box.calls.length} ${DECLARATION_TOOL_NAME} calls; exactly one declaration is expected`,
1220
+ ? `the reply delivered no ${channel.name} call — structure is enforced, prose is never parsed`
1221
+ : `the reply delivered ${box.calls.length} ${channel.name} calls; exactly one is expected`,
1032
1222
  axis: box.calls.length === 0 ? 'no-declaration-tool-call' : 'multiple-declaration-tool-calls',
1033
1223
  };
1034
1224
  } else {
1035
1225
  // THE FALLBACK PATH — providers without tool mode only. The ONE shipped
1036
1226
  // parser, never a second one.
1227
+ const at = channel.textPath ?? channel.name;
1037
1228
  const { code, red: extractRed } = extractArtifact(raw);
1038
1229
  if (code) {
1039
- try { return { declaration: JSON.parse(code), attempts, convo, raw, providerError: null, red: null, budget: null }; }
1040
- catch (e) { red = { code: 'artifact-red', path: 'declaration', detail: `the declaration did not parse as JSON: ${String(/** @type {any} */ (e)?.message ?? e)}`, axis: 'unparseable-json' }; }
1230
+ try { return { artifact: JSON.parse(code), attempts, convo, raw, providerError: null, red: null, budget: null }; }
1231
+ catch (e) { red = { code: 'artifact-red', path: at, detail: `the ${at} did not parse as JSON: ${String(/** @type {any} */ (e)?.message ?? e)}`, axis: 'unparseable-json' }; }
1041
1232
  } else {
1042
- red = { code: 'artifact-red', path: 'declaration', detail: `no artifact in the reply: ${extractRed}`, axis: 'no-artifact' };
1233
+ red = { code: 'artifact-red', path: at, detail: `no artifact in the reply: ${extractRed}`, axis: 'no-artifact' };
1043
1234
  }
1044
1235
  }
1045
1236
 
1046
1237
  if (attempt < retries) {
1047
1238
  const block = renderRejectBlock({ kind: 'artifact', reds: [/** @type {Red} */ (red).detail] });
1048
1239
  convo.push({ role: 'assistant', content: raw.trim() || '(the reply carried no text)' });
1049
- convo.push({ role: 'user', content: `${block}\n\n${mode === 'tool' ? STRUCTURE_INSTRUCTION_TOOL : STRUCTURE_INSTRUCTION_TEXT}` });
1240
+ convo.push({ role: 'user', content: `${block}\n\n${mode === 'tool' ? channel.instruction : STRUCTURE_INSTRUCTION_TEXT}` });
1050
1241
  }
1051
1242
  }
1052
- return { declaration: null, attempts, convo, raw: '', providerError: null, red, budget: null };
1243
+ return { artifact: null, attempts, convo, raw: '', providerError: null, red, budget: null };
1244
+ }
1245
+
1246
+ /** THE DECLARATION CHANNEL — the one this loop has always used. Its schema is
1247
+ * CLASS-SCOPED, like the prompt's catalogue block: the two must offer one set of
1248
+ * kinds, or the composer reads about a kind it cannot call.
1249
+ * @param {{catalogue: Record<string, any>, verdictType: string}} o */
1250
+ const declarationChannel = ({ catalogue, verdictType }) => ({
1251
+ name: DECLARATION_TOOL_NAME,
1252
+ instruction: STRUCTURE_INSTRUCTION_TOOL,
1253
+ textPath: 'declaration',
1254
+ tool: (/** @type {{calls: any[]}} */ box) => declarationTool(box, { catalogue, verdictType }),
1255
+ });
1256
+
1257
+ /** The declaration ask, over the shared ladder. `declaration` is `artifact`
1258
+ * under this channel's own name — the flow below reads a declaration, not an
1259
+ * anonymous artifact, and one word for two things is how a reader loses which.
1260
+ * @param {{messages: any[], generate: Function, mode: 'tool'|'text', retries: number,
1261
+ * label: string, book: ReturnType<typeof makeCostBook>, catalogue: Record<string, any>,
1262
+ * verdictType: string}} o
1263
+ */
1264
+ async function askDeclaration({ messages, generate, mode, retries, label, book, catalogue, verdictType }) {
1265
+ const r = await askStructured({
1266
+ messages, generate, mode, retries, label, book, channel: declarationChannel({ catalogue, verdictType }),
1267
+ });
1268
+ return { ...r, declaration: r.artifact };
1053
1269
  }
1054
1270
 
1055
1271
  /**
@@ -1085,6 +1301,8 @@ async function askDeclaration({ messages, generate, mode, retries, label, book,
1085
1301
  * listing?: {stop: string|null, files: string[]|null, block: string|null, meta: any}|null,
1086
1302
  * generate: Function, seedReadFn?: Function, closeCtx?: any,
1087
1303
  * ceilingUsd?: number|null,
1304
+ * onPhase?: (phase: string, data?: any) => void,
1305
+ * onCall?: (call: {label: string, costUsd: number|null, unpricedRounds: number}) => void,
1088
1306
  * maxRevisions?: number, structureRetries?: number,
1089
1307
  * structuredMode?: 'tool'|'text', catalogue?: Record<string, any>}} o
1090
1308
  */
@@ -1094,6 +1312,11 @@ export async function authorClose({
1094
1312
  scout, listing = null,
1095
1313
  generate, seedReadFn = runSeedReadStages, closeCtx = {},
1096
1314
  ceilingUsd = null,
1315
+ // The two REPORTING seams, defaulted to nothing so every existing caller is
1316
+ // byte-identical. Neither is consulted: the revise ladder, the ceiling and the
1317
+ // seed read all run exactly as they did — the shell is merely told, while the
1318
+ // run is still alive, which of the up-to-15 silent minutes it is inside.
1319
+ onPhase = () => {}, onCall = () => {},
1097
1320
  maxRevisions = MAX_REVISIONS,
1098
1321
  structureRetries = MAX_STRUCTURE_RETRIES,
1099
1322
  structuredMode = 'tool',
@@ -1118,7 +1341,7 @@ export async function authorClose({
1118
1341
  // no default for exactly this reason, and `shellCapUsd` left off judged against
1119
1342
  // a number nobody chose). An unbounded authoring run is legal; it is just never
1120
1343
  // an accident, which is why the driver PRINTS it.
1121
- const book = makeCostBook({ ceilingUsd });
1344
+ const book = makeCostBook({ ceilingUsd, onCall });
1122
1345
  // The scout's calls AND its raws, together. The raws matter most on the path
1123
1346
  // that spends nothing more: run mslhn707 refused at the $0 preflight below,
1124
1347
  // and the survey text that would have said WHY died with the process.
@@ -1306,7 +1529,12 @@ export async function authorClose({
1306
1529
  try {
1307
1530
  for (let i = 0; i <= revisionCap; i += 1) {
1308
1531
  const label = i === 0 ? 'author' : `revise-${i}`;
1309
- const ask = await askDeclaration({ messages, generate, mode: structuredMode, retries: retryCap, label, book, catalogue });
1532
+ // WHICH call, and how many the ladder may make. `of` is the ENFORCED cap
1533
+ // (`revisionCap`, already clamped tighten-only) rather than the constant —
1534
+ // a readout quoting a number the loop does not obey is a second
1535
+ // instrument, and this file's own history is what that costs.
1536
+ onPhase('author-call', { call: label, i, of: revisionCap });
1537
+ const ask = await askDeclaration({ messages, generate, mode: structuredMode, retries: retryCap, label, book, catalogue, verdictType });
1310
1538
  messages = ask.convo;
1311
1539
 
1312
1540
  // The cap tripped BEFORE this ask made any call at all, so there is no
@@ -1392,7 +1620,20 @@ export async function authorClose({
1392
1620
  // `gapKeep` defaults to EMPTY rather than being left undefined: M1
1393
1621
  // prefixes every gap line with it verbatim, so an unset one prints the
1394
1622
  // word "undefined" on every failure line the model is asked to read
1395
- const rows = await seedReadFn(injected.declaration, { gapKeep: '', ...closeCtx, workdir, seedRef, seedTrees });
1623
+ // THE LONG ONE. Every stage of the declaration spawns a real toolchain
1624
+ // against the patient, so this single await is where a run spends
1625
+ // minutes saying nothing — the per-stage line below is the whole point,
1626
+ // and the block boundary is what makes it readable.
1627
+ const stageCount = injected.declaration?.stages?.length ?? null;
1628
+ onPhase('seed-read', { call: label, stages: stageCount });
1629
+ // `onStage` sits AFTER the `closeCtx` spread beside `workdir`/`seedRef`:
1630
+ // it is the runner's own reporting channel, not a knob a caller's close
1631
+ // context may quietly redirect.
1632
+ const rows = await seedReadFn(injected.declaration, {
1633
+ gapKeep: '', ...closeCtx, workdir, seedRef, seedTrees,
1634
+ onStage: (/** @type {any} */ row) => onPhase('stage', { call: label, ...row }),
1635
+ });
1636
+ onPhase('seed-read-done', { call: label, stages: stageCount });
1396
1637
  iter.seedRead = rows;
1397
1638
  accepted = injected.declaration;
1398
1639
  acceptedSeedRead = rows;