bareloop 0.9.0 → 0.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/authorflow.js CHANGED
@@ -78,11 +78,11 @@ import { join, relative, isAbsolute } from 'node:path';
78
78
  import { seedRead as runSeedReadStages, makeSeedTrees, AGGREGATES, SIGNS, MAX_TERMS } from './kinds.js';
79
79
  import {
80
80
  KIND_CATALOGUE, MAX_STAGES, DIRECTIONS, BASELINES,
81
- GENRE_LANGUAGES, TYPES_GENRE_TEMPLATE, classGuards, genreEnv, genreOwnedEnvNames,
81
+ GENRE_LANGUAGES, TYPES_GENRE_TEMPLATE, classGuards, genreEnv, genreOwnedEnvNames, genreInstruments,
82
82
  validateDeclaration, envCapableKind, VERDICT_CLASSES, LIVE_CLASSES,
83
83
  } from './authoring.js';
84
- import { buildSeedListing, cleanEntry } from './authorscout.js';
85
- import { extractArtifact, priceOf, scrubRaw } from './text.js';
84
+ import { buildSeedListing, cleanEntry, SURVEY_CAUSES } from './authorscout.js';
85
+ import { extractArtifact, priceOf, scrubRaw, tallyCalls, capStop } from './text.js';
86
86
  import { redactSecrets } from './validate.js';
87
87
 
88
88
  const require = createRequire(import.meta.url);
@@ -163,21 +163,63 @@ export const STRUCTURE_INSTRUCTION_TEXT = 'Your reply could not be read as one J
163
163
  * cannot emit anything else (D2/D3), so a genre it has never seen is a
164
164
  * composition problem, not a catalogue problem.
165
165
  *
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.
166
+ * The GREEN set descends from questions 1–6 of the frozen TYPES set (prereg §5).
167
+ * TWO slots have been deleted since, both because the interview was ASKING FOR
168
+ * SOMETHING IT ALREADY HAD or already knew:
169
+ *
170
+ * - D13's genre CONFIRM ("this looks like a type-fixing job, correct?") —
171
+ * superseded in full: the interview never asks about a genre again, and the
172
+ * refusal it carried MOVED to the composer, on the same counted
173
+ * `request-red` path (see `authorCloseForJob`);
174
+ * - "Is there a code repo I can look at? Where?" — DROPPED on hamr's ruling
175
+ * after he drove the terminal interview himself (2026-08-15, verbatim: *"drop
176
+ * Q6"*). The repository is a MANDATORY, STRUCTURED input already —
177
+ * `runInterview`'s own `repoPath`, which the runner takes from `--patient` and
178
+ * refuses to start without. Asking a person to re-type in prose a path the
179
+ * machine is holding in a variable is the SWE tax this product refuses, and
180
+ * it invites a second, drifting answer for the same fact (hamr's own answer
181
+ * on the live run was *"Yes — the patient itself."*). Nothing downstream lost
182
+ * anything: the scout reads `repoPath`, never the answer.
183
+ *
184
+ * Question 2 was WIDENED in the same ruling: it now asks for the read-or-draw-from
185
+ * files as well as the ones that change. The two halves belong in one answer
186
+ * because they are one decision for the person answering — what the work touches,
187
+ * and what it touches only to look at.
188
+ *
189
+ * The five that remain are genre-neutral, which is why every generalisation so far
190
+ * has deleted a slot rather than reworded one. They are NUMBERED CONTIGUOUSLY from
191
+ * 1: the number a person sees is the key their answer is filed under, and a gap
192
+ * would be a hole in a form nobody can explain.
173
193
  */
174
194
  export const GREEN_QUESTIONS = Object.freeze({
175
195
  1: 'What do you want done?',
176
- 2: 'Which files or folders should change?',
196
+ 2: 'Which files or folders should change? And which files should the work read or draw from (they stay untouched)?',
177
197
  3: 'Is there anything that must not change?',
178
198
  4: 'How do you check today whether it\'s working?',
179
199
  5: 'What would make you say this came back worse than before?',
180
- 6: 'Is there a code repo I can look at? Where?',
200
+ });
201
+
202
+ /**
203
+ * THE HITL SET (N4 slice 1) — the green questions BYTE FOR BYTE, plus one.
204
+ *
205
+ * The five are unchanged deliberately rather than economically: the
206
+ * mechanical-first composition law (2026-08-07) says every hitl close is
207
+ * *deterministic stages first, judge minimal, human last*, so a hitl job needs
208
+ * exactly the same mechanical facts a green one does — what changes, what must
209
+ * not, how it is checked today, what "worse" looks like. Rewording them would
210
+ * fork one interview into two that drift.
211
+ *
212
+ * The last one is the only thing the green set cannot supply, and it is not a
213
+ * flourish: `human-confirms` requires an `ask` — the question the signer answers
214
+ * at the end of the run — and nothing else in the interview names it. Without it
215
+ * the composer would have to invent what a person is deciding, which is the one
216
+ * thing a human stage must never have done for it. It is the SPREAD's next number
217
+ * rather than a fixed one, so a green-side deletion renumbers it instead of
218
+ * leaving a gap (it was 7 while the green set held six, and is 6 now it holds five).
219
+ */
220
+ export const HITL_QUESTIONS = Object.freeze({
221
+ ...GREEN_QUESTIONS,
222
+ 6: 'When you look at the finished result yourself, what are you deciding?',
181
223
  });
182
224
 
183
225
  /**
@@ -195,7 +237,11 @@ export const QUESTION_SETS = Object.freeze({
195
237
  required: Object.freeze(Object.keys(GREEN_QUESTIONS).map(Number).sort((a, b) => a - b)),
196
238
  }),
197
239
  'soft-green': Object.freeze({ locked: true, questions: null, required: null }),
198
- hitl: Object.freeze({ locked: true, questions: null, required: null }),
240
+ hitl: Object.freeze({
241
+ locked: false,
242
+ questions: HITL_QUESTIONS,
243
+ required: Object.freeze(Object.keys(HITL_QUESTIONS).map(Number).sort((a, b) => a - b)),
244
+ }),
199
245
  });
200
246
 
201
247
  /**
@@ -214,7 +260,15 @@ export const CLASS_STATEMENTS = Object.freeze({
214
260
  + 'measurement of this repository. If part of what they asked for cannot be measured that way, leave it out and '
215
261
  + 'say so in your notes rather than approximating it with a stage that means something else.',
216
262
  'soft-green': null,
217
- hitl: null,
263
+ // N4 slice 1. The mechanical-first composition law is stated as an ORDER
264
+ // because first-red-wins makes the order the mechanism: the cheap stages shield
265
+ // the expensive one, and the person is never the first filter.
266
+ hitl: 'The person declared this job HITL: a PERSON renders the final verdict, because their judgement is what '
267
+ + '"done" means here. Compose the mechanical part first — everything about this repository a command can decide '
268
+ + 'is still a mechanical stage, exactly as it would be for a green job — and put ONE human-confirms stage LAST, '
269
+ + 'carrying the question they answer when they look at the result. Never make the person the first filter: a '
270
+ + 'stage a command can render is never handed to them, and they are asked only once, at the end, about the part '
271
+ + 'no command can hold.',
218
272
  });
219
273
 
220
274
  /** @param {string} verdictType @returns {{locked: boolean, questions: any, required: any}} */
@@ -354,6 +408,12 @@ export const PARAM_SCHEMAS = Object.freeze({
354
408
  extensions: { type: 'array', minItems: 1, items: { type: 'string' } },
355
409
  allowPrefixes: { type: 'array', minItems: 1, items: { type: 'string' } },
356
410
  requireNonEmpty: { const: true },
411
+ ask: {
412
+ type: 'string',
413
+ minLength: 1,
414
+ description: 'the plain question the person answers while looking at the finished result. They see it verbatim, '
415
+ + 'so write what they are DECIDING — never a bare "approve?".',
416
+ },
357
417
  });
358
418
 
359
419
  /**
@@ -496,6 +556,60 @@ THE PARSER reads ONE number out of a command's output, as signed arithmetic over
496
556
  not actually happen.`;
497
557
  }
498
558
 
559
+ /**
560
+ * HOW THE GENRE'S OWN TOOLS PRINT — rendered FROM `genreInstruments`, so the
561
+ * pattern the author is handed and the pattern the tree already grades with are
562
+ * one string.
563
+ *
564
+ * This block is the fix for run msmbpjk6: a parser for a tool WE OWN THE FACTS
565
+ * ABOUT was left to the model to compose, and it composed tsc's terminal-only
566
+ * shape against piped output — 0 matches on 67 real error lines. The delivery is
567
+ * deliberately the MANDATORY-GUARD delivery, not advice: the pattern arrives
568
+ * spelled out, marked genre property, with the real captured line beside it, in
569
+ * the same register as the guard battery that arrives already parameterised.
570
+ *
571
+ * It states the MECHANISM (pipe, not a terminal) rather than only the answer,
572
+ * because the same trap is waiting behind every tool that pretty-prints for a
573
+ * human, and only some of them are in the table yet. That prediction has since
574
+ * been paid off by measurement: `node --test` prints TAP down a pipe and the
575
+ * spec reporter on a terminal, the identical flip, on a second tool.
576
+ *
577
+ * Each row also carries its `capture`, because a count term and a tally term are
578
+ * read by DIFFERENT aggregates and the wrong pairing is a silent misreading, not
579
+ * an error — `first` over a line the tool omits on a good run reports a broken
580
+ * instrument. The block states that mapping once rather than per row.
581
+ * @param {string} lang
582
+ */
583
+ export function instrumentsBlock(lang) {
584
+ const rows = genreInstruments(lang).map((i) => `- ${i.id} — ${i.what}\n`
585
+ + ` lineMatch: ${i.lineMatch}\n`
586
+ + ` capture: ${i.capture === null ? 'null' : i.capture}\n`
587
+ + ` a REAL captured line: ${i.example}\n`
588
+ + ` why this spelling: ${i.why}`);
589
+ return `KNOWN INSTRUMENTS — how this genre's own tools PRINT, fixed by the genre, not yours to decide
590
+
591
+ A close captures output through a PIPE, never a terminal. Tools that pretty-print for a human
592
+ print something else entirely down a pipe, and the piped shape is the only one a close ever reads.
593
+ The patterns below are MEASURED against real captured output of this genre's tools.
594
+
595
+ Where a stage of yours counts one of these, use its lineMatch EXACTLY as written — a pattern you
596
+ compose yourself for one of these tools is a pattern for output that never arrives, and a term that
597
+ matches nothing does not report a small number, it reports a broken instrument.
598
+
599
+ Each row states its own capture, and that choice decides the aggregate:
600
+ capture: null the term TALLIES one per matching line. Use aggregate "sum" — over no matching
601
+ lines that is a legitimate counted zero.
602
+ capture: <n> the term reads the FIGURE the tool printed, out of that group. Use aggregate
603
+ "first" — and note that no match at all then means the tool never reported the
604
+ number, which is a broken instrument, never a zero. A count is listed this way
605
+ only where the tool prints the line even when the number is zero.
606
+
607
+ ${rows.join('\n\n')}
608
+
609
+ This covers only the tools listed. For anything else, you still compose the pattern yourself from
610
+ the facts object — and say in your notes when you had to.`;
611
+ }
612
+
499
613
  /** The three laws. Stated to the author; enforced, where checkable, by M2. */
500
614
  export function lawsBlock() {
501
615
  return `THE LAWS YOUR DECLARATION MUST OBEY
@@ -611,6 +725,11 @@ measure anything.`;
611
725
  + 'This job\'s genre carries a fixed close SHAPE. The following policy is law for your declaration. It names\n'
612
726
  + 'no command, no path, no environment value and no pattern for this repository — finding those is your work,\n'
613
727
  + `from the facts object and the interview:\n\n${TYPES_GENRE_TEMPLATE}`,
728
+ // The template deliberately names no regex for this repository — but the
729
+ // shape a KNOWN tool prints is not "for this repository", it is a fact about
730
+ // an instrument the genre already owns and already grades with. It sits here,
731
+ // immediately after the policy that mandates the typecheck stage.
732
+ instrumentsBlock(lang),
614
733
  'MANDATORY GUARDS — already placed and already parameterised\n\n'
615
734
  + 'This kind of job carries stages the person did not ask for. They are fixed by the job\'s genre and they are\n'
616
735
  + 'already in your declaration, with their pattern battery and extensions already filled in — those are genre\n'
@@ -853,7 +972,19 @@ export function resolveSourcePrefixes({ workdir, sourcePaths, seedFiles }) {
853
972
  * run could not be read afterwards. A cost entry with no raw beside it is that
854
973
  * run again.
855
974
  */
856
- export function makeCostBook() {
975
+ /**
976
+ * @param {{ceilingUsd?: number|null,
977
+ * onCall?: (call: {label: string, costUsd: number|null, unpricedRounds: number}) => void}} [o]
978
+ * `ceilingUsd` is the OPERATOR's ceiling; `null`/absent is UNBOUNDED.
979
+ * `onCall` is a REPORTING seam fired as each metered call lands — the shell's
980
+ * only chance to know what has been spent while the run is still alive. It is
981
+ * never consulted and never governs: `capStop` below is still the one
982
+ * predicate, still asked between calls, and still reads the same `entries`.
983
+ * `absorb` deliberately does NOT fire it: those calls were metered (and
984
+ * reported) by whoever made them, and reporting them twice would double the
985
+ * scout's spend in every reader downstream.
986
+ */
987
+ export function makeCostBook({ ceilingUsd = null, onCall = () => {} } = {}) {
857
988
  /** @type {{label: string, costUsd: number|null, unpricedRounds: number}[]} */
858
989
  const entries = [];
859
990
  /** @type {ReturnType<typeof scrubRaw>[]} */
@@ -861,8 +992,10 @@ export function makeCostBook() {
861
992
  return {
862
993
  /** @param {string} label @param {any} r a bare-agent run result @param {number} [attempt] */
863
994
  add(label, r, attempt = 1) {
864
- entries.push({ label, ...priceOf(r) });
995
+ const call = { label, ...priceOf(r) };
996
+ entries.push(call);
865
997
  raws.push(scrubRaw({ label, attempt, text: r?.text, reason: r?.error ? String(r.error) : null }));
998
+ onCall({ ...call });
866
999
  return entries.at(-1);
867
1000
  },
868
1001
  /** @param {{label: string, costUsd: number|null, unpricedRounds: number}[]} calls
@@ -872,19 +1005,19 @@ export function makeCostBook() {
872
1005
  * `report().calls`, because both are readings of one list */
873
1006
  raws() { return raws.map((w) => ({ ...w })); },
874
1007
  report() {
875
- const known = entries.reduce((a, e) => a + (typeof e.costUsd === 'number' ? e.costUsd : 0), 0);
876
- const nullCostCalls = entries.filter((e) => e.costUsd === null).length;
877
- const unpricedRounds = entries.reduce((a, e) => a + (e.unpricedRounds || 0), 0);
878
- const spendComplete = nullCostCalls === 0 && unpricedRounds === 0;
879
- return {
880
- costUsd: spendComplete ? Number(known.toFixed(6)) : null,
881
- knownUsd: Number(known.toFixed(6)),
882
- spendComplete,
883
- nullCostCalls,
884
- unpricedRounds,
885
- calls: entries.map((e) => ({ ...e })),
886
- };
1008
+ return { ...tallyCalls(entries), calls: entries.map((e) => ({ ...e })) };
887
1009
  },
1010
+ /**
1011
+ * THE CEILING'S OWN READING, asked between metered calls. The book is the one
1012
+ * place this pipeline's spend accumulates, so it is the one place that can
1013
+ * answer honestly — and it answers through the shared predicate rather than a
1014
+ * second hand-spelled comparison (`src/text.js`, beside `priceOf`).
1015
+ * @returns {'cap-halt'|'pricing-red'|null}
1016
+ */
1017
+ capStop() { return capStop({ ceilingUsd, ...tallyCalls(entries) }); },
1018
+ /** what was set, stated — `null` is "nobody set one", which is UNBOUNDED and
1019
+ * a visible operator choice, never a defaulted number */
1020
+ ceilingUsd,
888
1021
  };
889
1022
  }
890
1023
 
@@ -939,6 +1072,16 @@ async function askDeclaration({ messages, generate, mode, retries, label, book,
939
1072
  let red = null;
940
1073
 
941
1074
  for (let attempt = 0; attempt <= retries; attempt += 1) {
1075
+ // THE CEILING, immediately before the paid call and nowhere else. Sited HERE
1076
+ // rather than in the revise loop because this is where every declaration call
1077
+ // is actually made: the author call, each revise, AND each malformed-emission
1078
+ // retry. The retry axis is the one that matters most — it fires exactly when
1079
+ // the model is malforming, which is the worst moment for a paid loop to be
1080
+ // unbounded. `attempts` is returned as-is so the caller can tell a cap that
1081
+ // tripped before this ask began (0) from one that cut a retry ladder short.
1082
+ const halt = book.capStop();
1083
+ if (halt) return { declaration: null, attempts, convo, raw: '', providerError: null, red: null, budget: halt };
1084
+
942
1085
  /** @type {{calls: any[]}} */
943
1086
  const box = { calls: [] };
944
1087
  const tools = mode === 'tool' ? [declarationTool(box, { catalogue })] : [];
@@ -948,11 +1091,11 @@ async function askDeclaration({ messages, generate, mode, retries, label, book,
948
1091
  const raw = redactSecrets(String(r?.text ?? ''));
949
1092
 
950
1093
  if (r?.error) {
951
- return { declaration: null, attempts, convo, raw, providerError: String(r.error), red: null };
1094
+ return { declaration: null, attempts, convo, raw, providerError: String(r.error), red: null, budget: null };
952
1095
  }
953
1096
 
954
1097
  if (mode === 'tool') {
955
- if (box.calls.length === 1) return { declaration: box.calls[0], attempts, convo, raw, providerError: null, red: null };
1098
+ if (box.calls.length === 1) return { declaration: box.calls[0], attempts, convo, raw, providerError: null, red: null, budget: null };
956
1099
  red = {
957
1100
  code: 'artifact-red',
958
1101
  path: DECLARATION_TOOL_NAME,
@@ -966,7 +1109,7 @@ async function askDeclaration({ messages, generate, mode, retries, label, book,
966
1109
  // parser, never a second one.
967
1110
  const { code, red: extractRed } = extractArtifact(raw);
968
1111
  if (code) {
969
- try { return { declaration: JSON.parse(code), attempts, convo, raw, providerError: null, red: null }; }
1112
+ try { return { declaration: JSON.parse(code), attempts, convo, raw, providerError: null, red: null, budget: null }; }
970
1113
  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' }; }
971
1114
  } else {
972
1115
  red = { code: 'artifact-red', path: 'declaration', detail: `no artifact in the reply: ${extractRed}`, axis: 'no-artifact' };
@@ -979,7 +1122,7 @@ async function askDeclaration({ messages, generate, mode, retries, label, book,
979
1122
  convo.push({ role: 'user', content: `${block}\n\n${mode === 'tool' ? STRUCTURE_INSTRUCTION_TOOL : STRUCTURE_INSTRUCTION_TEXT}` });
980
1123
  }
981
1124
  }
982
- return { declaration: null, attempts, convo, raw: '', providerError: null, red };
1125
+ return { declaration: null, attempts, convo, raw: '', providerError: null, red, budget: null };
983
1126
  }
984
1127
 
985
1128
  /**
@@ -1010,9 +1153,13 @@ async function askDeclaration({ messages, generate, mode, retries, label, book,
1010
1153
  *
1011
1154
  * @param {{workdir: string, seedRef: string, lang: string, verdictType: string,
1012
1155
  * answers: Record<string|number, string>, questions?: Record<string|number, string>,
1013
- * scout: {state: string, facts: any, reason?: string|null, calls?: any[], raws?: any[]},
1156
+ * scout: {state: string, facts: any, reason?: string|null, cause?: string|null, calls?: any[], raws?: any[],
1157
+ * budgetStop?: 'cap-halt'|'pricing-red'|null},
1014
1158
  * listing?: {stop: string|null, files: string[]|null, block: string|null, meta: any}|null,
1015
1159
  * generate: Function, seedReadFn?: Function, closeCtx?: any,
1160
+ * ceilingUsd?: number|null,
1161
+ * onPhase?: (phase: string, data?: any) => void,
1162
+ * onCall?: (call: {label: string, costUsd: number|null, unpricedRounds: number}) => void,
1016
1163
  * maxRevisions?: number, structureRetries?: number,
1017
1164
  * structuredMode?: 'tool'|'text', catalogue?: Record<string, any>}} o
1018
1165
  */
@@ -1021,6 +1168,12 @@ export async function authorClose({
1021
1168
  answers, questions = GREEN_QUESTIONS,
1022
1169
  scout, listing = null,
1023
1170
  generate, seedReadFn = runSeedReadStages, closeCtx = {},
1171
+ ceilingUsd = null,
1172
+ // The two REPORTING seams, defaulted to nothing so every existing caller is
1173
+ // byte-identical. Neither is consulted: the revise ladder, the ceiling and the
1174
+ // seed read all run exactly as they did — the shell is merely told, while the
1175
+ // run is still alive, which of the up-to-15 silent minutes it is inside.
1176
+ onPhase = () => {}, onCall = () => {},
1024
1177
  maxRevisions = MAX_REVISIONS,
1025
1178
  structureRetries = MAX_STRUCTURE_RETRIES,
1026
1179
  structuredMode = 'tool',
@@ -1039,7 +1192,13 @@ export async function authorClose({
1039
1192
  // ceiling. Floor 0, exactly as above: `askDeclaration` loops `attempt <= retries`,
1040
1193
  // so 0 is still ONE attempt with no retry — a legal tighter ask, not an absent one.
1041
1194
  const retryCap = Math.max(0, Math.min(Math.trunc(Number(structureRetries)) || 0, MAX_STRUCTURE_RETRIES));
1042
- const book = makeCostBook();
1195
+ // THE OPERATOR'S CEILING — NO DEFAULT, and none is invented here. `null` means
1196
+ // nobody set one, which is UNBOUNDED: a defaulted cap is a silent second
1197
+ // ceiling, and the run path already paid for that lesson twice (`maxWallMs` has
1198
+ // no default for exactly this reason, and `shellCapUsd` left off judged against
1199
+ // a number nobody chose). An unbounded authoring run is legal; it is just never
1200
+ // an accident, which is why the driver PRINTS it.
1201
+ const book = makeCostBook({ ceilingUsd, onCall });
1043
1202
  // The scout's calls AND its raws, together. The raws matter most on the path
1044
1203
  // that spends nothing more: run mslhn707 refused at the $0 preflight below,
1045
1204
  // and the survey text that would have said WHY died with the process.
@@ -1057,6 +1216,40 @@ export async function authorClose({
1057
1216
  // special facts are needed". This one lands in a SIGNED artefact.
1058
1217
  if (scout?.state !== 'PRESENT' || !scout.facts || typeof scout.facts !== 'object'
1059
1218
  || Object.keys(scout.facts).length === 0) {
1219
+ // A SURVEY THE CEILING STOPPED IS NOT A SURVEY THAT FAILED. The scout carries
1220
+ // its own governance stop, and when it is set that is the honest name for why
1221
+ // the facts are missing — the same rule W-2 settled for the clock (a time-stop
1222
+ // is NAMED a time-stop). `scout-absent` sends the operator to the scout; a
1223
+ // cap-halt sends them to the number they set, which is the one that moves.
1224
+ if (scout?.budgetStop) {
1225
+ // A CONCURRENT CAUSE IS CONCEDED, NEVER DENIED. The ceiling can refuse the
1226
+ // reserved recovery round for a call that ALSO died on transport, and both
1227
+ // are then true at once. "not on anything it read" would deny the very
1228
+ // error the same sentence goes on to quote, and this red carries
1229
+ // `path: budgetUsd` — so the operator is steered to raise a ceiling into a
1230
+ // dead socket, which is the one lever that cannot repair it. The stop
1231
+ // itself does not move: the ceiling did bind, and naming the second cause
1232
+ // is attribution, not routing.
1233
+ //
1234
+ // The transport death is not the only second cause. The ceiling also
1235
+ // refuses the recovery round behind a SHORT/EMPTY reply, and the retry
1236
+ // behind an EMPTY/UNPARSEABLE one — every cause except NOT_FUNDED means a
1237
+ // real call produced the reason being quoted, so "not on anything it
1238
+ // read" is honest ONLY when no call was ever made.
1239
+ const alsoDied = scout?.cause === SURVEY_CAUSES.CALL_FAILED;
1240
+ const alsoIncomplete = !alsoDied && scout?.cause != null && scout.cause !== SURVEY_CAUSES.NOT_FUNDED;
1241
+ return refuse([{
1242
+ code: scout.budgetStop,
1243
+ path: 'budgetUsd',
1244
+ detail: alsoDied
1245
+ ? 'the authoring ceiling bound here AND the survey call itself failed — both are true, and raising the '
1246
+ + `ceiling will not repair the second: ${redactSecrets(String(scout.reason ?? ''))}`
1247
+ : alsoIncomplete
1248
+ ? 'the authoring ceiling bound here AND the survey came back incomplete — both are true: the ceiling '
1249
+ + `refused the round that could have repaired it: ${redactSecrets(String(scout.reason ?? ''))}`
1250
+ : `the survey stopped on the authoring ceiling, not on anything it read: ${redactSecrets(String(scout.reason ?? ''))}`,
1251
+ }], scout.budgetStop);
1252
+ }
1060
1253
  return refuse([{
1061
1254
  code: 'scout-absent',
1062
1255
  path: 'scout',
@@ -1158,12 +1351,60 @@ export async function authorClose({
1158
1351
  /** @type {{stage: string, name: string}[]} */
1159
1352
  let droppedEnv = [];
1160
1353
 
1354
+ /**
1355
+ * THE GOVERNANCE STOP'S OWN RED. It names the CAP and the SPEND, because the
1356
+ * operator's next move is a NUMBER — resume-to-cap is the shipped lever (the
1357
+ * stop IS the checkpoint), and a stop that reports neither side of the
1358
+ * comparison cannot be acted on. It is a governance stop, never an error: the
1359
+ * partial artifacts stay exactly where they are, nothing retries, and nothing
1360
+ * about the close itself is being judged here.
1361
+ * @param {'cap-halt'|'pricing-red'} code
1362
+ * @returns {Red}
1363
+ */
1364
+ const capRed = (code) => {
1365
+ const c = book.report();
1366
+ const spent = `$${c.knownUsd.toFixed(6)} across ${c.calls.length} metered call(s)`;
1367
+ return code === 'pricing-red'
1368
+ ? {
1369
+ code,
1370
+ path: 'budgetUsd',
1371
+ detail: `the authoring ceiling cannot be enforced: ${c.nullCostCalls} unpriced call(s) and ${c.unpricedRounds} `
1372
+ + `unpriced round(s) make the total unknown, against a $${ceilingUsd} ceiling (${spent} is the KNOWN half). `
1373
+ + 'Unpriced is never free (F6) — a cap that cannot see the spend cannot govern it, so the run stops rather '
1374
+ + 'than spending against a floor that reads as exact.',
1375
+ }
1376
+ : {
1377
+ code,
1378
+ path: 'budgetUsd',
1379
+ detail: `the authoring ceiling is spent: ${spent} against a $${ceilingUsd} ceiling. The next call was never `
1380
+ + 'made — the cap binds BETWEEN calls, so nothing was cut off mid-flight and everything paid for is on '
1381
+ + 'disk. The stop IS the checkpoint: raise the ceiling and re-run, or read what is here and stop.',
1382
+ };
1383
+ };
1384
+
1161
1385
  const seedTrees = makeSeedTrees();
1162
1386
  try {
1163
1387
  for (let i = 0; i <= revisionCap; i += 1) {
1164
1388
  const label = i === 0 ? 'author' : `revise-${i}`;
1389
+ // WHICH call, and how many the ladder may make. `of` is the ENFORCED cap
1390
+ // (`revisionCap`, already clamped tighten-only) rather than the constant —
1391
+ // a readout quoting a number the loop does not obey is a second
1392
+ // instrument, and this file's own history is what that costs.
1393
+ onPhase('author-call', { call: label, i, of: revisionCap });
1165
1394
  const ask = await askDeclaration({ messages, generate, mode: structuredMode, retries: retryCap, label, book, catalogue });
1166
1395
  messages = ask.convo;
1396
+
1397
+ // The cap tripped BEFORE this ask made any call at all, so there is no
1398
+ // iteration to record: an iteration row for a call that never happened is
1399
+ // the blind-instrument class one field wide (`ralph` already pays for the
1400
+ // sibling of this — a governance stop narrated for a loop it never
1401
+ // entered). A ladder cut SHORT is the other case and falls through: those
1402
+ // calls did happen, so the row stays and the stop is read after it.
1403
+ if (ask.budget && ask.attempts === 0) {
1404
+ fatal = [capRed(ask.budget)];
1405
+ stop = ask.budget;
1406
+ break;
1407
+ }
1167
1408
  /** @type {any} */
1168
1409
  const iter = {
1169
1410
  call: label, attempts: ask.attempts, declaration: ask.declaration ?? null,
@@ -1172,6 +1413,17 @@ export async function authorClose({
1172
1413
  };
1173
1414
  iterations.push(iter);
1174
1415
 
1416
+ // A retry ladder cut short by the cap. The money stop OUTRANKS the artifact
1417
+ // red that was in flight: the artifact red says the model malformed, the cap
1418
+ // says we stopped paying to find out — and only the second one is why the
1419
+ // loop ended. Reporting the first would send the operator to the model when
1420
+ // the fact is the wallet.
1421
+ if (ask.budget) {
1422
+ fatal = [capRed(ask.budget)];
1423
+ stop = ask.budget;
1424
+ break;
1425
+ }
1426
+
1175
1427
  if (ask.providerError) {
1176
1428
  // a transport failure is a casualty, never evidence about the model — and
1177
1429
  // the transport's own prose (a proxy URL, an auth echo) is scrubbed on the
@@ -1225,7 +1477,20 @@ export async function authorClose({
1225
1477
  // `gapKeep` defaults to EMPTY rather than being left undefined: M1
1226
1478
  // prefixes every gap line with it verbatim, so an unset one prints the
1227
1479
  // word "undefined" on every failure line the model is asked to read
1228
- const rows = await seedReadFn(injected.declaration, { gapKeep: '', ...closeCtx, workdir, seedRef, seedTrees });
1480
+ // THE LONG ONE. Every stage of the declaration spawns a real toolchain
1481
+ // against the patient, so this single await is where a run spends
1482
+ // minutes saying nothing — the per-stage line below is the whole point,
1483
+ // and the block boundary is what makes it readable.
1484
+ const stageCount = injected.declaration?.stages?.length ?? null;
1485
+ onPhase('seed-read', { call: label, stages: stageCount });
1486
+ // `onStage` sits AFTER the `closeCtx` spread beside `workdir`/`seedRef`:
1487
+ // it is the runner's own reporting channel, not a knob a caller's close
1488
+ // context may quietly redirect.
1489
+ const rows = await seedReadFn(injected.declaration, {
1490
+ gapKeep: '', ...closeCtx, workdir, seedRef, seedTrees,
1491
+ onStage: (/** @type {any} */ row) => onPhase('stage', { call: label, ...row }),
1492
+ });
1493
+ onPhase('seed-read-done', { call: label, stages: stageCount });
1229
1494
  iter.seedRead = rows;
1230
1495
  accepted = injected.declaration;
1231
1496
  acceptedSeedRead = rows;