bareloop 0.8.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.
@@ -0,0 +1,1447 @@
1
+ // THE WRITING HALF — close-authoring v1, module M3b (design record 2026-08-07
2
+ // D3/D11/D12; gate-2 POC prereg addenda 5 and 6).
3
+ //
4
+ // M1 owns what a stage DOES. M2 owns what a declaration may SAY and whether one
5
+ // said it legally. This module is the part that gets a declaration out of a model
6
+ // and refuses to accept a bad one — the authoring call, the schema that bounds
7
+ // it, and the grounded revise loop that measures it against the real repository
8
+ // before anyone signs anything.
9
+ //
10
+ // ── THE ONE STRUCTURAL DECISION (hamr, 2026-08-08) ──────────────────────────
11
+ //
12
+ // The declaration is emitted through a SCHEMA-FORCED TOOL CALL, never parsed out
13
+ // of prose. Standing doctrine: *"text mode will NOT force JSON output; the
14
+ // structure-enforced alternative is tool mode."* The model answers by calling
15
+ // `declare_close`, whose input schema IS the declaration shape, and that schema
16
+ // is DERIVED from M2's catalogue — one source, never a second hand-written copy.
17
+ //
18
+ // This does NOT reopen D3 (*"the authoring call gets no tools at all"*). D3's
19
+ // subject is hamr's law — *"the danger is in the ACTIONS, not the SYNTAX"* — and
20
+ // `declare_close` takes no action: it has no fence to cross because it touches
21
+ // nothing, spawns nothing and reads nothing. It is the OUTPUT CHANNEL, in the
22
+ // place a fenced JSON block used to sit. The authoring call still has no repo
23
+ // tool, no shell, and no way to reach the tree; the scout (M3a) is where looking
24
+ // happens, and it is a different call with a different grant.
25
+ //
26
+ // TWO CONSEQUENCES, NAMED RATHER THAN SMOOTHED:
27
+ //
28
+ // 1. A LOCKED KIND IS INEXPRESSIBLE IN TOOL MODE. The schema carries one branch
29
+ // per LIVE kind, so there is no way to spell `judged-floor` or
30
+ // `human-confirms` — which is the `check-passes(name)` move applied one
31
+ // level up (illegal becomes unsayable, not rejected late). The price is
32
+ // real: M2's `locked-kind` red is bareloop's DEMAND COUNTER for its own
33
+ // catalogue (D13 forward-compat point 2), and a demand that cannot be
34
+ // spoken cannot be counted. On the tool path, locked-kind demand must come
35
+ // from the INTERVIEW layer (D13's genre/verdict refusal, which is where a
36
+ // user's "I want a person to sign this off" actually lands) or from the text
37
+ // fallback below. This module counts what it can see and says so; it does
38
+ // not pretend the channel is intact.
39
+ //
40
+ // 2. THE TEXT PATH SURVIVES ONLY AS A FALLBACK for a provider with no tool
41
+ // mode. It is `structuredMode: 'text'`, it is marked everywhere it appears,
42
+ // and it uses the ONE shipped parser (`extractArtifact`). It is not a
43
+ // recovery path for a model that ignored the tool: that is an ARTIFACT-RED —
44
+ // name the axis, retry under a bound, write nothing.
45
+ //
46
+ // ── THE GROUNDED LOOP (addendum 5, lever L2) ────────────────────────────────
47
+ //
48
+ // author → validate → run EVERY stage at the seed → feed the MEASURED results
49
+ // back → revise. Bounded at two revisions, early-stop when the model returns its
50
+ // declaration unchanged. The feedback is EXECUTION OUTPUT and nothing else: no
51
+ // model ever reviews another model's close (D9), because a model grading its own
52
+ // homework is the self-consistency trap the design already bans. The revise turn
53
+ // is exactly `measured block + one frozen instruction`, and `assertReviseTurn`
54
+ // is what makes that a fact instead of a promise.
55
+ //
56
+ // A validation red is fed back the same way and COUNTS as a revision — it is a
57
+ // measurement of the declaration, made by the validator. A malformed EMISSION is
58
+ // not: it is a transport fault, retried under its own bound, and it never eats
59
+ // the model's revision budget.
60
+ //
61
+ // ── WHAT THIS MODULE INJECTS, AND WHY IT IS NOT THE MODEL'S JOB ─────────────
62
+ //
63
+ // The genre's environment (addendum 6's second residue). `MYPYPATH` on a Python
64
+ // patient is the Result-5 fact no user can supply and no model found: round 2's
65
+ // arm B authored it itself and authored it WRONG. It is applied MECHANICALLY to
66
+ // every stage that spawns something, a model-authored copy is DROPPED and
67
+ // ANNOUNCED, and a genre variable this patient cannot supply is a RED — because
68
+ // it moves no seed number, so no seed-verdict read can ever catch its absence.
69
+ // The model is never handed its own injected copy back, or M2 would red it as
70
+ // `genre-owned-env` on the next turn for a variable the model did not write.
71
+ //
72
+ // F6 throughout: an unpriced call makes the TOTAL unknown, and unknown is null.
73
+ // No spine emission here — M4 wires events.
74
+
75
+ import { createRequire } from 'node:module';
76
+ import { realpathSync } from 'node:fs';
77
+ import { join, relative, isAbsolute } from 'node:path';
78
+ import { seedRead as runSeedReadStages, makeSeedTrees, AGGREGATES, SIGNS, MAX_TERMS } from './kinds.js';
79
+ import {
80
+ KIND_CATALOGUE, MAX_STAGES, DIRECTIONS, BASELINES,
81
+ GENRE_LANGUAGES, TYPES_GENRE_TEMPLATE, classGuards, genreEnv, genreOwnedEnvNames, genreInstruments,
82
+ validateDeclaration, envCapableKind, VERDICT_CLASSES, LIVE_CLASSES,
83
+ } from './authoring.js';
84
+ import { buildSeedListing, cleanEntry, SURVEY_CAUSES } from './authorscout.js';
85
+ import { extractArtifact, priceOf, scrubRaw, tallyCalls, capStop } from './text.js';
86
+ import { redactSecrets } from './validate.js';
87
+
88
+ const require = createRequire(import.meta.url);
89
+ const { Loop } = require('bare-agent');
90
+
91
+ /** @typedef {{code: string, path: string, detail: string, [k: string]: any}} Red */
92
+
93
+ // ── constants ────────────────────────────────────────────────────────────────
94
+
95
+ /** the output channel's name. It takes no action; it carries the answer. */
96
+ export const DECLARATION_TOOL_NAME = 'declare_close';
97
+ /** what the tool returns. The loop stops on delivery, so the model never reads it. */
98
+ export const DECLARATION_ACK = 'declaration received';
99
+ /** addendum 5: unlimited revising launders thrash as adaptation (v1.12's rule). */
100
+ export const MAX_REVISIONS = 2;
101
+ /** a malformed EMISSION is a transport fault, bounded on its own axis */
102
+ export const MAX_STRUCTURE_RETRIES = 2;
103
+ /** per-stage gap lines carried into a revise turn, trim announced (F28) */
104
+ export const REVISE_GAP_CAP = 15;
105
+ /** validator reds carried into a revise turn, trim announced (F28) */
106
+ export const REVISE_RED_CAP = 20;
107
+ /** F18 / F30 — caching on, and the 4096 provider default cannot hold a declaration */
108
+ export const AUTHOR_MAX_TOKENS = 32000;
109
+ /** the one slot the model fills on an injected guard */
110
+ export const FILL_MARKER = '<FILL IN>';
111
+
112
+ /**
113
+ * ONE red, scrubbed — the same spelling `prepareSigning` uses (src/authorjob.js),
114
+ * and for the same reason: a validation red QUOTES what the model declared, and
115
+ * these copies are persisted (`iterations[].validation`, this module's returned
116
+ * `reds`) rather than only rendered.
117
+ *
118
+ * UNIFORM over every string-valued field, never a named list of them. `detail` is
119
+ * only the loudest echo — `cmd-denied` quotes the command again as a structured
120
+ * `cmd`, `path-not-in-listing` carries `declared`/`resolved`, and the next red to
121
+ * learn a field would be missed by an allowlist written today. `redactSecrets`
122
+ * returns a non-matching string byte-identical, so `code`/`path` pass through
123
+ * unchanged and the uniform map costs them nothing.
124
+ * @param {Red} r @returns {Red}
125
+ */
126
+ const scrubRed = (r) => /** @type {any} */ (Object.fromEntries(
127
+ Object.entries(r).map(([k, v]) => [k, typeof v === 'string' ? redactSecrets(v) : v]),
128
+ ));
129
+
130
+ /**
131
+ * THE FROZEN REVISE INSTRUCTION — gate-2 round 3's own text, byte for byte
132
+ * (`scratch-gate2/author.mjs:63`, the turn that PASSED both arms). It is the only
133
+ * prose a revise turn may carry, this constant is its only source, and the suite
134
+ * asserts its bytes: an edit reds the suite and forces a decision rather than
135
+ * quietly changing what every future close is corrected against.
136
+ */
137
+ export const REVISE_INSTRUCTION = 'These are the measured results of running your close against the UNCHANGED repository (the seed). '
138
+ + 'The work has not been done yet. Judge your own declaration against them: a work stage that is GREEN at '
139
+ + 'seed is grading nothing; an instrument stop means the stage cannot run at all; a count far from what the '
140
+ + 'interview describes means the instrument is measuring the wrong thing. Return the full corrected '
141
+ + 'declaration, or return it unchanged if you believe it is correct.';
142
+
143
+ /**
144
+ * The structure re-ask, per mode. DELIBERATELY NOT `REVISE_INSTRUCTION`: nothing
145
+ * has been measured, there is no declaration to judge, and reusing the revise
146
+ * text would tell a model to "judge its own declaration against them" when there
147
+ * is no them.
148
+ */
149
+ export const STRUCTURE_INSTRUCTION_TOOL = `Your reply did not deliver exactly one declaration through the ${DECLARATION_TOOL_NAME} tool. `
150
+ + `Call ${DECLARATION_TOOL_NAME} exactly once, with the whole declaration as its arguments. Do not write the declaration as text.`;
151
+ export const STRUCTURE_INSTRUCTION_TEXT = 'Your reply could not be read as one JSON declaration. '
152
+ + 'Reply with ONLY the JSON object, in a single fenced block, and nothing else.';
153
+
154
+ /**
155
+ * THE INTERVIEW, KEYED BY VERDICT CLASS (hamr's ruling, PRD v1.57 §2).
156
+ *
157
+ * One question set per GENRE has no terminating shape — genres are a fat long
158
+ * tail that cannot be enumerated, and TYPES was the first specimen rather than
159
+ * the pattern. One set per verdict CLASS has exactly three. Genre understanding
160
+ * does not disappear: it moves into the COMPOSITION over the kind catalogue
161
+ * (`CLASS_STATEMENTS` below, rendered into the authoring prompt), which is the
162
+ * layer already built to hold it — the authoring call composes owned kinds and
163
+ * cannot emit anything else (D2/D3), so a genre it has never seen is a
164
+ * composition problem, not a catalogue problem.
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.
173
+ */
174
+ export const GREEN_QUESTIONS = Object.freeze({
175
+ 1: 'What do you want done?',
176
+ 2: 'Which files or folders should change?',
177
+ 3: 'Is there anything that must not change?',
178
+ 4: 'How do you check today whether it\'s working?',
179
+ 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
+ });
182
+
183
+ /**
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.
189
+ * @type {Record<string, {locked: boolean, questions: Record<string|number, string>|null, required: readonly number[]|null}>}
190
+ */
191
+ export const QUESTION_SETS = Object.freeze({
192
+ green: Object.freeze({
193
+ locked: false,
194
+ questions: GREEN_QUESTIONS,
195
+ required: Object.freeze(Object.keys(GREEN_QUESTIONS).map(Number).sort((a, b) => a - b)),
196
+ }),
197
+ 'soft-green': Object.freeze({ locked: true, questions: null, required: null }),
198
+ hitl: Object.freeze({ locked: true, questions: null, required: null }),
199
+ });
200
+
201
+ /**
202
+ * WHERE GENRE UNDERSTANDING NOW LIVES: the plain statement the composer is fed —
203
+ * *the user has this problem and declared it green/deterministic*. It is the
204
+ * user's own answer travelling into the composition call, and the composer is
205
+ * what turns it into stages over owned kinds.
206
+ *
207
+ * A locked class states NOTHING rather than something bland: a sentence here
208
+ * would be a class this build can compose for, and it cannot.
209
+ * @type {Record<string, string|null>}
210
+ */
211
+ export const CLASS_STATEMENTS = Object.freeze({
212
+ green: 'The person declared this job GREEN: their definition of done is MACHINE-CHECKABLE. A command has to be able '
213
+ + 'to decide it — no judgement, no score, and no person in the loop. Every stage you compose is a mechanical '
214
+ + 'measurement of this repository. If part of what they asked for cannot be measured that way, leave it out and '
215
+ + 'say so in your notes rather than approximating it with a stage that means something else.',
216
+ 'soft-green': null,
217
+ hitl: null,
218
+ });
219
+
220
+ /** @param {string} verdictType @returns {{locked: boolean, questions: any, required: any}} */
221
+ function questionSet(verdictType) {
222
+ const set = Object.hasOwn(QUESTION_SETS, String(verdictType)) ? QUESTION_SETS[String(verdictType)] : null;
223
+ if (!set) throw new Error(`no interview set for verdict class "${verdictType}" — one of ${VERDICT_CLASSES.join(', ')}`);
224
+ if (set.locked || set.questions === null) {
225
+ throw new Error(`the "${verdictType}" interview set is LOCKED — that class is declared-but-locked in v1 and `
226
+ + 'admission refuses it before its questions are ever asked');
227
+ }
228
+ return set;
229
+ }
230
+
231
+ /** The frozen question set for a picked class. Throws on a locked or unknown
232
+ * one: reaching here with either means admission did not refuse, which is a
233
+ * bareloop bug, and failing loudly beats interviewing for a class we cannot run.
234
+ * @param {string} verdictType */
235
+ export function questionsFor(verdictType) { return questionSet(verdictType).questions; }
236
+ /** The question numbers whose answer must be present for that class's interview
237
+ * to be finished. @param {string} verdictType @returns {number[]} */
238
+ export function requiredAnswersFor(verdictType) { return [...questionSet(verdictType).required]; }
239
+
240
+ export const AUTHOR_SYSTEM = 'You compose the DEFINITION OF DONE for an automated job: a declaration over a fixed '
241
+ + 'catalogue of stage kinds whose implementations already exist. You never write code, a script, a shell fragment, '
242
+ + 'a new kind, or a new parameter name. You cannot read anything and you cannot run anything: everything you know '
243
+ + 'about this repository is in the message you are given.';
244
+
245
+ // ── 1. THE SCHEMA, DERIVED FROM THE CATALOGUE ───────────────────────────────
246
+
247
+ /** `{includePrefixes?, excludePrefixes?}` — one definition, both kinds that take it */
248
+ const SCOPE_SCHEMA = Object.freeze({
249
+ type: 'object',
250
+ description: 'which population this stage counts. A path prefix, read off the seed listing.',
251
+ properties: {
252
+ includePrefixes: { type: 'array', minItems: 1, items: { type: 'string' } },
253
+ excludePrefixes: { type: 'array', minItems: 1, items: { type: 'string' } },
254
+ },
255
+ additionalProperties: false,
256
+ });
257
+
258
+ /** the parser, in both legal forms. Enums come from M1's own exported constants. */
259
+ const PARSER_SCHEMA = Object.freeze({
260
+ description: 'how to read ONE number out of the command output: the signed sum over terms of '
261
+ + '(first | sum of that term\'s captures, inside that term\'s region).',
262
+ oneOf: [
263
+ {
264
+ title: 'terms',
265
+ type: 'object',
266
+ properties: {
267
+ terms: {
268
+ type: 'array',
269
+ minItems: 1,
270
+ maxItems: MAX_TERMS,
271
+ items: {
272
+ type: 'object',
273
+ properties: {
274
+ lineMatch: { type: 'string', description: 'a regular expression, applied line by line' },
275
+ capture: { type: 'integer', minimum: 0, description: 'which capture group holds the number (default 1). A pattern with NO group tallies 1 per match.' },
276
+ sign: { enum: [...SIGNS], description: '+1 adds this term, -1 subtracts it' },
277
+ aggregate: { enum: [...AGGREGATES], description: '"sum" over nothing is a counted zero; "first" over nothing is UNKNOWN, and unknown is an instrument stop' },
278
+ region: {
279
+ oneOf: [
280
+ { const: 'whole-output' },
281
+ {
282
+ type: 'object',
283
+ properties: {
284
+ anchor: { type: 'string' },
285
+ capture: { type: 'integer', minimum: 0, description: '0 = the whole anchor match' },
286
+ },
287
+ required: ['anchor', 'capture'],
288
+ additionalProperties: false,
289
+ },
290
+ ],
291
+ },
292
+ },
293
+ required: ['lineMatch', 'sign', 'aggregate', 'region'],
294
+ additionalProperties: false,
295
+ },
296
+ },
297
+ },
298
+ required: ['terms'],
299
+ additionalProperties: false,
300
+ },
301
+ {
302
+ title: 'short form',
303
+ type: 'object',
304
+ description: 'exactly one term with sign +1, aggregate "first", region "whole-output"',
305
+ properties: {
306
+ lineMatch: { type: 'string' },
307
+ capture: { type: 'integer', minimum: 0 },
308
+ },
309
+ required: ['lineMatch'],
310
+ additionalProperties: false,
311
+ },
312
+ ],
313
+ });
314
+
315
+ /**
316
+ * ONE schema per PARAMETER NAME, composed into kinds by the catalogue's own
317
+ * `required`/`optional` lists. Keyed by name rather than by kind on purpose: the
318
+ * catalogue already decides which kind takes which parameter, and a per-kind
319
+ * table would be a second place for that fact to live.
320
+ *
321
+ * What is genuinely NEW here is only the JSON TYPE of each leaf — the catalogue
322
+ * states it as prose (`shape`) for the prompt, and nothing in the tree states it
323
+ * as a schema. Everything a schema could DRIFT on — which kinds exist, which are
324
+ * locked, which parameters each takes, which values an enum admits — is read
325
+ * from M2 and M1, and {@link schemaCoverage} makes the remaining seam mechanical:
326
+ * a catalogue that grows a parameter this table does not know about fails LOUDLY
327
+ * at schema-build time rather than silently omitting it from what the model can
328
+ * say.
329
+ */
330
+ export const PARAM_SCHEMAS = Object.freeze({
331
+ cmd: { type: 'string', description: 'the executable, invoked so its binary actually resolves' },
332
+ args: { type: 'array', items: { type: 'string' } },
333
+ expectExit: { type: 'integer' },
334
+ timeoutMs: { type: 'integer', minimum: 1 },
335
+ env: { type: 'object', additionalProperties: { type: 'string' } },
336
+ parser: PARSER_SCHEMA,
337
+ scope: SCOPE_SCHEMA,
338
+ direction: { enum: [...DIRECTIONS] },
339
+ baseline: {
340
+ enum: [...BASELINES],
341
+ description: '"seed" is the same number taken from this run\'s own starting point, measured every run; '
342
+ + '0 is literally zero. You never write a number you measured yourself.',
343
+ },
344
+ patterns: {
345
+ type: 'array',
346
+ minItems: 1,
347
+ items: {
348
+ type: 'object',
349
+ properties: { id: { type: 'string' }, regex: { type: 'string' } },
350
+ required: ['id', 'regex'],
351
+ additionalProperties: false,
352
+ },
353
+ },
354
+ extensions: { type: 'array', minItems: 1, items: { type: 'string' } },
355
+ allowPrefixes: { type: 'array', minItems: 1, items: { type: 'string' } },
356
+ requireNonEmpty: { const: true },
357
+ });
358
+
359
+ /**
360
+ * The mechanical seam between the catalogue and the schema, read BOTH ways: a
361
+ * catalogue parameter with no schema would be silently undeclarable, and a schema
362
+ * entry the catalogue does not have is a second catalogue.
363
+ * @param {Record<string, any>} [catalogue]
364
+ * @returns {{ok: boolean, missing: string[], orphan: string[]}}
365
+ */
366
+ export function schemaCoverage(catalogue = KIND_CATALOGUE) {
367
+ /** @type {string[]} */ const missing = [];
368
+ /** @type {Set<string>} */ const used = new Set();
369
+ for (const [kind, spec] of Object.entries(catalogue)) {
370
+ if (spec.locked) continue;
371
+ for (const p of [...spec.required, ...spec.optional]) {
372
+ used.add(p);
373
+ if (!Object.hasOwn(PARAM_SCHEMAS, p)) missing.push(`${kind}.${p}`);
374
+ }
375
+ }
376
+ const orphan = Object.keys(PARAM_SCHEMAS).filter((p) => !used.has(p));
377
+ return { ok: missing.length === 0 && orphan.length === 0, missing, orphan };
378
+ }
379
+
380
+ /**
381
+ * THE DECLARATION SCHEMA — the tool's input schema, and therefore the whole
382
+ * shape a model can express.
383
+ *
384
+ * One `oneOf` branch per LIVE kind, each pinning its own `kind` as a const so a
385
+ * parameter set cannot drift onto the wrong kind. A LOCKED kind gets no branch at
386
+ * all: declaring one is inexpressible here rather than rejected afterwards (see
387
+ * the header for what that costs).
388
+ *
389
+ * It enforces STRUCTURE, not LAW. The stage-name slug rule, the one-population
390
+ * law, the listing rule, F49's regex reject and the D5 guard equality are M2's,
391
+ * and they arrive as validation reds through the revise loop — a schema that
392
+ * tried to restate them would be a second validator.
393
+ *
394
+ * THROWS on a catalogue it cannot cover: a malformed catalogue is a bareloop bug,
395
+ * and failing loudly beats emitting a schema that quietly cannot say something
396
+ * the validator will then demand.
397
+ * @param {Record<string, any>} [catalogue]
398
+ */
399
+ export function declarationSchema(catalogue = KIND_CATALOGUE) {
400
+ const cov = schemaCoverage(catalogue);
401
+ if (!cov.ok) {
402
+ throw new Error('[authorflow] the declaration schema does not cover the catalogue — '
403
+ + `missing: ${cov.missing.join(', ') || 'none'}; orphaned: ${cov.orphan.join(', ') || 'none'}`);
404
+ }
405
+ const branches = Object.entries(catalogue)
406
+ .filter(([, spec]) => !spec.locked)
407
+ .map(([kind, spec]) => {
408
+ /** @type {Record<string, any>} */
409
+ const properties = {};
410
+ for (const p of [...spec.required, ...spec.optional]) properties[p] = PARAM_SCHEMAS[/** @type {keyof typeof PARAM_SCHEMAS} */ (p)];
411
+ return {
412
+ title: kind,
413
+ description: spec.asserts,
414
+ type: 'object',
415
+ properties: {
416
+ name: { type: 'string', description: 'a unique lowercase-hyphenated slug saying what this stage asserts' },
417
+ kind: { const: kind },
418
+ params: { type: 'object', properties, required: [...spec.required], additionalProperties: false },
419
+ },
420
+ required: ['name', 'kind', 'params'],
421
+ additionalProperties: false,
422
+ };
423
+ });
424
+
425
+ return {
426
+ type: 'object',
427
+ properties: {
428
+ stages: {
429
+ type: 'array',
430
+ minItems: 1,
431
+ maxItems: MAX_STAGES,
432
+ description: 'the close, in FIRST-RED-WINS order — the first red ends the judgement',
433
+ items: { oneOf: branches },
434
+ },
435
+ notes: {
436
+ type: 'array',
437
+ // minLength 1 for the standing reason: where only a closed set (here,
438
+ // "a string that says something") can satisfy a field, the illegal value
439
+ // is made INEXPRESSIBLE rather than rejected after the fact. The
440
+ // validator reds an empty note either way — this stops it being written.
441
+ items: { type: 'string', minLength: 1 },
442
+ description: 'anything you could not express, or any fact you needed and did not have',
443
+ },
444
+ },
445
+ required: ['stages'],
446
+ additionalProperties: false,
447
+ };
448
+ }
449
+
450
+ /**
451
+ * The output channel. It records and acknowledges; it takes no action, which is
452
+ * why handing it to a toolless authoring call does not reopen D3.
453
+ * @param {{calls: any[]}} box
454
+ * @param {{catalogue?: Record<string, any>}} [o]
455
+ */
456
+ export function declarationTool(box, { catalogue = KIND_CATALOGUE } = {}) {
457
+ return {
458
+ name: DECLARATION_TOOL_NAME,
459
+ description: 'Deliver the finished close. Call this exactly once, with the whole declaration as its arguments.',
460
+ parameters: declarationSchema(catalogue),
461
+ execute: async (/** @type {any} */ args) => { box.calls.push(args); return DECLARATION_ACK; },
462
+ };
463
+ }
464
+
465
+ // ── 2. THE PROMPT ────────────────────────────────────────────────────────────
466
+
467
+ /**
468
+ * The catalogue, as the authoring call sees it — rendered FROM the catalogue, so
469
+ * the prompt and the validator judge one set of facts. `shape` and `asserts` are
470
+ * M2's own strings.
471
+ * @param {Record<string, any>} [catalogue]
472
+ */
473
+ export function catalogueBlock(catalogue = KIND_CATALOGUE) {
474
+ const rows = Object.entries(catalogue).map(([name, spec]) => (spec.locked
475
+ ? `- ${name} — LOCKED. Not available in v1; it will not run.`
476
+ : `- ${name}\n ${spec.shape}\n required: ${spec.required.join(', ')}`
477
+ + `${spec.optional.length ? ` optional: ${spec.optional.join(', ')}` : ''}\n asserts: ${spec.asserts}`));
478
+ return `THE KIND CATALOGUE — these are the only kinds that exist.
479
+ You parameterise kinds. You never write a script, a shell fragment, or a new kind.
480
+
481
+ ${rows.join('\n')}
482
+
483
+ THE PARSER reads ONE number out of a command's output, as signed arithmetic over terms:
484
+
485
+ value = the sum over terms of sign x (first | sum of that term's matches, inside its region)
486
+
487
+ lineMatch a regular expression, applied to the output line by line.
488
+ capture which capture group holds the number (default 1). A pattern with NO capture group
489
+ contributes 1 per match — that is how you TALLY occurrences instead of reading a
490
+ figure the tool printed.
491
+ aggregate ${AGGREGATES.join(' | ')}. "sum" over nothing is a counted zero. "first" over nothing
492
+ means the number was never reported, which is a broken instrument, not a zero.
493
+ region "whole-output", or {anchor, capture} to search only inside the part of the output the
494
+ anchor locates. A region that cannot be located is a broken instrument.
495
+ sign ${SIGNS.join(' | ')}. Subtraction is how you exclude things that were reported but did
496
+ not actually happen.`;
497
+ }
498
+
499
+ /**
500
+ * HOW THE GENRE'S OWN TOOLS PRINT — rendered FROM `genreInstruments`, so the
501
+ * pattern the author is handed and the pattern the tree already grades with are
502
+ * one string.
503
+ *
504
+ * This block is the fix for run msmbpjk6: a parser for a tool WE OWN THE FACTS
505
+ * ABOUT was left to the model to compose, and it composed tsc's terminal-only
506
+ * shape against piped output — 0 matches on 67 real error lines. The delivery is
507
+ * deliberately the MANDATORY-GUARD delivery, not advice: the pattern arrives
508
+ * spelled out, marked genre property, with the real captured line beside it, in
509
+ * the same register as the guard battery that arrives already parameterised.
510
+ *
511
+ * It states the MECHANISM (pipe, not a terminal) rather than only the answer,
512
+ * because the same trap is waiting behind every tool that pretty-prints for a
513
+ * human, and only some of them are in the table yet. That prediction has since
514
+ * been paid off by measurement: `node --test` prints TAP down a pipe and the
515
+ * spec reporter on a terminal, the identical flip, on a second tool.
516
+ *
517
+ * Each row also carries its `capture`, because a count term and a tally term are
518
+ * read by DIFFERENT aggregates and the wrong pairing is a silent misreading, not
519
+ * an error — `first` over a line the tool omits on a good run reports a broken
520
+ * instrument. The block states that mapping once rather than per row.
521
+ * @param {string} lang
522
+ */
523
+ export function instrumentsBlock(lang) {
524
+ const rows = genreInstruments(lang).map((i) => `- ${i.id} — ${i.what}\n`
525
+ + ` lineMatch: ${i.lineMatch}\n`
526
+ + ` capture: ${i.capture === null ? 'null' : i.capture}\n`
527
+ + ` a REAL captured line: ${i.example}\n`
528
+ + ` why this spelling: ${i.why}`);
529
+ return `KNOWN INSTRUMENTS — how this genre's own tools PRINT, fixed by the genre, not yours to decide
530
+
531
+ A close captures output through a PIPE, never a terminal. Tools that pretty-print for a human
532
+ print something else entirely down a pipe, and the piped shape is the only one a close ever reads.
533
+ The patterns below are MEASURED against real captured output of this genre's tools.
534
+
535
+ Where a stage of yours counts one of these, use its lineMatch EXACTLY as written — a pattern you
536
+ compose yourself for one of these tools is a pattern for output that never arrives, and a term that
537
+ matches nothing does not report a small number, it reports a broken instrument.
538
+
539
+ Each row states its own capture, and that choice decides the aggregate:
540
+ capture: null the term TALLIES one per matching line. Use aggregate "sum" — over no matching
541
+ lines that is a legitimate counted zero.
542
+ capture: <n> the term reads the FIGURE the tool printed, out of that group. Use aggregate
543
+ "first" — and note that no match at all then means the tool never reported the
544
+ number, which is a broken instrument, never a zero. A count is listed this way
545
+ only where the tool prints the line even when the number is zero.
546
+
547
+ ${rows.join('\n\n')}
548
+
549
+ This covers only the tools listed. For anything else, you still compose the pattern yourself from
550
+ the facts object — and say in your notes when you had to.`;
551
+ }
552
+
553
+ /** The three laws. Stated to the author; enforced, where checkable, by M2. */
554
+ export function lawsBlock() {
555
+ return `THE LAWS YOUR DECLARATION MUST OBEY
556
+
557
+ 1. ONE POPULATION PER STAGE.
558
+ A stage name owns exactly one population of things being counted or checked. Two structurally
559
+ different counts never share a stage name, because their results land in one trend series and a
560
+ run that merely swapped one wall for another then reads as progress. Every counting stage over a
561
+ job that touches only part of the tree must say WHICH population it counts: its scope carries
562
+ includePrefixes or excludePrefixes. A scope object with neither names nothing.
563
+
564
+ 2. FIRST RED WINS — order cheap before expensive.
565
+ Stages run in the order you declare them and the first red ends the judgement. Put the cheap,
566
+ fast, structural stages first so they shield the slow ones.
567
+
568
+ 3. MECHANICAL FIRST.
569
+ Every assertion that CAN be a command IS a command. Nothing is left to judgement, to prose, or to
570
+ a person. If you cannot express something with the kinds above, leave it out and say so in your
571
+ notes — do not approximate it with a stage that means something else.
572
+
573
+ 4. EVERY PATH IS SELECTED, NEVER INVENTED.
574
+ Any path you write down must appear in the seed file listing above. A file is not called what it
575
+ sounds like it should be called.`;
576
+ }
577
+
578
+ /**
579
+ * The whole authoring prompt, as one string.
580
+ *
581
+ * Two people fed this brief and neither of them is the model: a person who is not
582
+ * a software engineer answered an interview, and a read-only survey produced the
583
+ * facts. The prompt says so, because round 1 measured what happens when a model
584
+ * treats a lay answer as a specification.
585
+ *
586
+ * `verdictType` is REQUIRED and has no default. It is the user's own answer and
587
+ * the one thing that tells the composer what DONE is allowed to mean here; a
588
+ * defaulted class would be the prompt answering a question the user was asked.
589
+ *
590
+ * @param {{answers: Record<string|number, string>, questions?: Record<string|number, string>,
591
+ * facts: any, listingBlock: string, lang: string, verdictType: string,
592
+ * guards: {name: string, kind: string, params: Record<string, any>, fill: string[]}[],
593
+ * ownedEnvNames?: string[], mode?: 'tool'|'text', catalogue?: Record<string, any>}} o
594
+ */
595
+ export function authorPrompt({ answers, questions = GREEN_QUESTIONS, facts, listingBlock, lang, verdictType, guards, ownedEnvNames = [], mode = 'tool', catalogue = KIND_CATALOGUE }) {
596
+ const statement = CLASS_STATEMENTS[String(verdictType)] ?? null;
597
+ if (statement === null) {
598
+ throw new Error(`[authorflow] the authoring prompt needs the verdict class the user picked, and "${verdictType}" `
599
+ + `composes nothing here — v1 composes ${Object.entries(CLASS_STATEMENTS).filter(([, v]) => v).map(([k]) => k).join(', ')} only`);
600
+ }
601
+ const interview = Object.entries(questions)
602
+ .map(([n, q]) => `Q${n}. ${q}\nA${n}. ${answers?.[n] ?? '(no answer given)'}`)
603
+ .join('\n\n');
604
+
605
+ const role = `You are composing the close for one job.
606
+
607
+ The close is what decides whether a run of this job came back DONE. It runs after the work is
608
+ finished, it is the only truth, and nothing else can overrule it. It is a list of STAGES. Each
609
+ stage names a kind from the catalogue below and fills that kind's parameters. Stages run in the
610
+ order you declare them.
611
+
612
+ Two people fed you this brief and neither of them is you:
613
+ - the person who wants the job done answered an interview, below, in their own words. They are
614
+ not a software engineer. They did not name commands, thresholds or patterns, and you must not
615
+ assume they know what any of those would be;
616
+ - a read-only survey of their repository produced the facts object, below. It is the only thing
617
+ you know about the repository. If a fact you need is not in it, say so in your notes rather
618
+ than inventing it.`;
619
+
620
+ // The guards arrive FULLY PARAMETERISED — the battery and its scope are genre
621
+ // property (addendum 6). Only a declared `fill` slot is the model's, and it is
622
+ // marked. Round 3's arm A narrowed a guard's scan and called it stricter.
623
+ const guardJson = guards.map((g) => ({
624
+ name: g.name,
625
+ kind: g.kind,
626
+ params: { ...g.params, ...Object.fromEntries(g.fill.map((f) => [f, FILL_MARKER])) },
627
+ }));
628
+
629
+ const envLine = ownedEnvNames.length
630
+ ? `\n\nThis job's genre sets ${ownedEnvNames.join(', ')} for you, from facts you were not given. `
631
+ + 'Do not declare any of those variables yourself — a declaration that sets one is rejected.'
632
+ : '';
633
+
634
+ const output = mode === 'tool'
635
+ ? `HOW TO ANSWER
636
+
637
+ Call the ${DECLARATION_TOOL_NAME} tool exactly once. Its arguments ARE the declaration: an ordered
638
+ "stages" array, plus "notes" for anything you could not express or any fact you needed and did not
639
+ have. Do not write the declaration as text.
640
+
641
+ Stage names are unique. Every parameter name comes from the catalogue and no other. No parameter is
642
+ left as a placeholder. You never write a threshold number you measured yourself, because you cannot
643
+ measure anything.`
644
+ : `HOW TO ANSWER
645
+
646
+ A single JSON object and nothing else: an ordered "stages" array, plus "notes" for anything you
647
+ could not express or any fact you needed and did not have.
648
+
649
+ Stage names are unique. Every parameter name comes from the catalogue and no other. No parameter is
650
+ left as a placeholder. You never write a threshold number you measured yourself, because you cannot
651
+ measure anything.`;
652
+
653
+ return [
654
+ role,
655
+ // WHERE GENRE UNDERSTANDING LIVES NOW (PRD v1.57 §2). The interview no longer
656
+ // asks what KIND of job this is; the person answered what DONE means, and the
657
+ // composer reads that plus the catalogue and works the rest out.
658
+ `WHAT THE PERSON DECLARED "DONE" TO MEAN — their answer, not yours\n\n${statement}`,
659
+ `THE INTERVIEW — the person's own words\n\n${interview}`,
660
+ `THE FACTS OBJECT — from a read-only survey of the repository\n\n${JSON.stringify(facts, null, 2)}`
661
+ + (listingBlock ? `\n\n${listingBlock}` : ''),
662
+ catalogueBlock(catalogue),
663
+ lawsBlock(),
664
+ `THE GENRE TEMPLATE — fixed by the job's genre (${lang}), not yours to decide\n\n`
665
+ + 'This job\'s genre carries a fixed close SHAPE. The following policy is law for your declaration. It names\n'
666
+ + 'no command, no path, no environment value and no pattern for this repository — finding those is your work,\n'
667
+ + `from the facts object and the interview:\n\n${TYPES_GENRE_TEMPLATE}`,
668
+ // The template deliberately names no regex for this repository — but the
669
+ // shape a KNOWN tool prints is not "for this repository", it is a fact about
670
+ // an instrument the genre already owns and already grades with. It sits here,
671
+ // immediately after the policy that mandates the typecheck stage.
672
+ instrumentsBlock(lang),
673
+ 'MANDATORY GUARDS — already placed and already parameterised\n\n'
674
+ + 'This kind of job carries stages the person did not ask for. They are fixed by the job\'s genre and they are\n'
675
+ + 'already in your declaration, with their pattern battery and extensions already filled in — those are genre\n'
676
+ + `property and you may not change, drop, rename, or re-kind them. The ONLY thing you fill in is each\n`
677
+ + `"${FILL_MARKER}" slot, from the facts object and the interview:\n\n${JSON.stringify(guardJson, null, 2)}\n\n`
678
+ + 'Place them where the ordering law says they belong, and author the remaining stages around them.'
679
+ + envLine,
680
+ output,
681
+ ].join('\n\n---\n\n');
682
+ }
683
+
684
+ // ── 3. THE MEASURED BLOCKS ───────────────────────────────────────────────────
685
+
686
+ /** F6, at the rendering boundary: an absent number is unknown, never 0. */
687
+ const show = (/** @type {any} */ v) => (v === null || v === undefined ? 'unknown' : String(v));
688
+
689
+ /**
690
+ * The seed read, as the model sees it: mechanical labels, no editorial. Per-stage
691
+ * gap capped with the trim ANNOUNCED (F28 — a bound that eats the failure names
692
+ * deletes exactly the mechanical detail that converts).
693
+ *
694
+ * SCRUBBED at the emission boundary. What this block renders is raw subprocess
695
+ * output (gap lines, an instrument stop carrying a command's stderr) and it
696
+ * becomes the next USER TURN sent to the provider — the surface most likely to
697
+ * carry a real credential, crossing the network. It goes through the ONE shared
698
+ * redactor, the same one `askDeclaration` puts the model's own text through and
699
+ * the same one `declaredclose`/`authorjob` put these very fields through; never
700
+ * a second inventory. Applied to the whole rendered block rather than one field,
701
+ * so a line added here is scrubbed by construction.
702
+ * @param {any[]} rows M1 `StageResult[]`
703
+ * @param {{gapCap?: number}} [o]
704
+ */
705
+ export function renderSeedReadBlock(rows, { gapCap = REVISE_GAP_CAP } = {}) {
706
+ const lines = ['MEASURED SEED-READ — every stage of your declaration was executed against the repository at the seed:'];
707
+ for (const r of rows ?? []) {
708
+ lines.push('', `stage "${r.stage}" [${r.kind}]`);
709
+ lines.push(` verdict: ${r.verdict} · exit code: ${show(r.exitCode)}`);
710
+ if (r.value !== null && r.value !== undefined) {
711
+ lines.push(` value: ${show(r.value)} · baseline: ${show(r.baseline)}`
712
+ + `${r.baselineSource ? ` (baseline ${r.baselineSource})` : ''}`);
713
+ }
714
+ if (r.detail?.stop) lines.push(` instrument-stop: ${r.detail.stop}`);
715
+ const gap = r.gapLines ?? [];
716
+ if (gap.length) {
717
+ lines.push(' gap:');
718
+ for (const g of gap.slice(0, gapCap)) lines.push(` ${g}`);
719
+ if (gap.length > gapCap) {
720
+ lines.push(` [gap trimmed: ${gap.length - gapCap} of ${gap.length} lines withheld — the cap is ${gapCap} per stage]`);
721
+ }
722
+ }
723
+ }
724
+ return redactSecrets(lines.join('\n'));
725
+ }
726
+
727
+ /**
728
+ * The measured block when the declaration never reached execution. Both cases are
729
+ * machine outputs of the pipeline, never editorial prose about the model's work.
730
+ *
731
+ * SCRUBBED at the emission boundary, for the SAME reason `renderSeedReadBlock`
732
+ * is and through the SAME one redactor. The channel here is different and just
733
+ * as real: a validation red QUOTES WHAT THE MODEL DECLARED, verbatim — the
734
+ * genre-env branch echoes the expected and the declared value, the deny floor
735
+ * echoes the whole `cmd`, the listing rule echoes the path — and this block
736
+ * becomes the next USER TURN sent to the provider. A declared value shaped like
737
+ * a credential crossed unmasked before this line existed. Applied to the whole
738
+ * rendered block rather than one field, so a red added to the validator is
739
+ * scrubbed by construction rather than by remembering.
740
+ * @param {{kind: 'validation'|'artifact', reds: (Red|string)[], cap?: number}} o
741
+ */
742
+ export function renderRejectBlock({ kind, reds, cap = REVISE_RED_CAP }) {
743
+ const head = kind === 'validation'
744
+ ? 'MEASURED VALIDATION — your declaration was rejected by the validator before any stage could run:'
745
+ : 'MEASURED PARSE — your reply could not be read as one declaration:';
746
+ const rendered = (reds ?? []).map((r) => (typeof r === 'string' ? r : `${r.code} at ${r.path || '(root)'}: ${r.detail}`));
747
+ const shown = rendered.slice(0, cap);
748
+ const tail = rendered.length > shown.length
749
+ ? [` [${rendered.length - shown.length} of ${rendered.length} reds withheld — the cap is ${cap}]`]
750
+ : [];
751
+ return redactSecrets([head, ...shown.map((r) => ` ${r}`), ...tail].join('\n'));
752
+ }
753
+
754
+ /** A revise turn is EXACTLY the measured block, a blank line, and the instruction. */
755
+ export function buildReviseTurn(/** @type {string} */ measuredBlock) {
756
+ return `${measuredBlock}\n\n${REVISE_INSTRUCTION}`;
757
+ }
758
+
759
+ /**
760
+ * The structural guarantee that no other prose can enter a revise turn. Without
761
+ * it "the feedback is measurement only" is a promise; with it, it is a fact that
762
+ * fails the build the day someone appends a helpful sentence.
763
+ * @param {string} turn @param {string} measuredBlock
764
+ */
765
+ export function assertReviseTurn(turn, measuredBlock) {
766
+ if (!/^MEASURED (SEED-READ|VALIDATION|PARSE) —/.test(measuredBlock)) {
767
+ throw new Error('REVISE DRIFT: the measured block does not open with a mechanical header');
768
+ }
769
+ if (turn !== `${measuredBlock}\n\n${REVISE_INSTRUCTION}`) {
770
+ throw new Error('REVISE DRIFT: the turn is not measured-block + the frozen instruction, exactly');
771
+ }
772
+ return { ok: true, bytes: Buffer.byteLength(turn) };
773
+ }
774
+
775
+ // ── 4. THE GENRE'S ENVIRONMENT ───────────────────────────────────────────────
776
+
777
+ /** Which kinds can carry an environment at all — read off the catalogue, never a
778
+ * list, and read through M2's OWN predicate rather than a second copy of it.
779
+ * The validator's completeness rule demands the variable on exactly the stages
780
+ * this injector puts it on, so the two must be one function: a drift either way
781
+ * leaves stages injected-but-unchecked or checked-but-never-injected. */
782
+ const envCapable = envCapableKind;
783
+
784
+ /**
785
+ * Apply the genre's environment to a declaration, mechanically.
786
+ *
787
+ * Three rules, and each one is a finding:
788
+ * - it lands on every stage that SPAWNS something (env-capable by the
789
+ * catalogue) and nowhere else — a stage that runs no process has no
790
+ * environment to set;
791
+ * - a model-authored copy of a genre-owned name is DROPPED and ANNOUNCED. M2's
792
+ * validator already reds that, so this is defence in depth — but the failure
793
+ * direction matters: silently keeping the model's guess is how round 2's arm
794
+ * B graded an unedited checkout;
795
+ * - a genre variable this patient cannot supply is a RED, not an omission. It
796
+ * moves no seed number, so no seed-verdict read can ever see it missing, and
797
+ * the artefact it would be missing from is SIGNED.
798
+ *
799
+ * The input declaration is never mutated: the model's own copy has to stay what
800
+ * the model wrote, or the next turn's validator reds it for our injection.
801
+ * @param {any} declaration
802
+ * @param {string} lang
803
+ * @param {{sourcePrefixes?: string[], catalogue?: Record<string, any>}} [o]
804
+ * @returns {{declaration: any, applied: Record<string, string>, dropped: {stage: string, name: string}[],
805
+ * missing: string[], reds: Red[]}}
806
+ */
807
+ export function applyGenreEnv(declaration, lang, { sourcePrefixes = [], catalogue = KIND_CATALOGUE } = {}) {
808
+ const applied = genreEnv(lang, { sourcePrefixes });
809
+ const owned = genreOwnedEnvNames(lang);
810
+ const missing = owned.filter((n) => !Object.hasOwn(applied, n));
811
+ /** @type {{stage: string, name: string}[]} */
812
+ const dropped = [];
813
+ /** @type {Red[]} */
814
+ const reds = missing.map((name) => ({
815
+ code: 'genre-env-unavailable',
816
+ path: `genre.env.${name}`,
817
+ detail: `the ${lang} genre owns ${name} and this patient supplies nothing to build it from — it is a fact `
818
+ + 'the interview cannot give and the repository does not state, it moves no seed number, and a close signed '
819
+ + 'without it grades a different checkout',
820
+ name,
821
+ }));
822
+
823
+ const out = structuredClone(declaration);
824
+ for (const s of out?.stages ?? []) {
825
+ if (!s || typeof s !== 'object' || !envCapable(s.kind, catalogue)) continue;
826
+ if (!s.params || typeof s.params !== 'object') continue;
827
+ /** @type {Record<string, string>} */
828
+ const env = { ...(s.params.env ?? {}) };
829
+ const conflicts = owned.filter((n) => Object.hasOwn(env, n));
830
+ // touch nothing when there is nothing to do: a declaration is a SIGNED
831
+ // artefact, and rewriting a stage we had no reason to change makes the
832
+ // signed form differ from what the model wrote for no stated reason
833
+ if (conflicts.length === 0 && Object.keys(applied).length === 0) continue;
834
+ for (const name of conflicts) { dropped.push({ stage: s.name, name }); delete env[name]; }
835
+ Object.assign(env, applied);
836
+ if (Object.keys(env).length) s.params.env = env;
837
+ else delete s.params.env;
838
+ }
839
+ return { declaration: out, applied, dropped, missing, reds };
840
+ }
841
+
842
+ /**
843
+ * The prefixes `genreEnv` JOINS into the genre's variable, resolved against the
844
+ * real tree. Two filters, and the second one was paid for on a live patient.
845
+ *
846
+ * EXISTENCE (unchanged): a prefix that names nothing at the seed would build a
847
+ * variable pointing at nothing, and the grounded validator refuses it anyway
848
+ * (`ungroundedGenreEnv` checks every ELEMENT against the seed listing).
849
+ *
850
+ * IDENTITY (new): existence alone reads a SYMLINK and the directory behind it as
851
+ * two prefixes. On aurora, 11 entries under `src` resolve into the per-package
852
+ * `packages/<pkg>/src` trees, so the joined `MYPYPATH` named one tree twice
853
+ * under two spellings and mypy died FATALLY — exit 2, "shadows library module",
854
+ * zero output — which the close reads as a broken instrument on a perfectly
855
+ * healthy patient. The realpath-deduped control produced the identical clean
856
+ * reading as the known-good invocation.
857
+ *
858
+ * WHICH SPELLING SURVIVES is not cosmetic. The REAL path wins, because that is
859
+ * the one the checker itself resolves to; the symlink spelling is kept only when
860
+ * the real one cannot be used. And a spelling is only ever emitted when it
861
+ * SELECTS from the seed listing, because the arbiter records this value in a
862
+ * SIGNED envelope and re-validates it GROUNDED — a value the grounded validator
863
+ * cannot select is one the arbiter must never build. So a link out of the
864
+ * repository, or into a path git does not track, keeps its declared spelling
865
+ * rather than becoming an absolute path nothing can ground.
866
+ *
867
+ * An unreadable candidate (a dangling link, a race) is NOT dropped: it passed the
868
+ * existence filter against the listing, and silently losing a real prefix is the
869
+ * Result-5 hazard in the other direction.
870
+ * @param {{workdir: string, sourcePaths: any, seedFiles: string[]}} o
871
+ * @returns {string[]}
872
+ */
873
+ export function resolveSourcePrefixes({ workdir, sourcePaths, seedFiles }) {
874
+ const files = Array.isArray(seedFiles) ? seedFiles : [];
875
+ const listed = (/** @type {string} */ p) => p !== '' && files.some((f) => f === p || f.startsWith(`${p}/`));
876
+ const candidates = (Array.isArray(sourcePaths) ? sourcePaths : []).map(cleanEntry).filter(listed);
877
+
878
+ // the root's OWN realpath: a workdir reached through a link would otherwise
879
+ // make every candidate look like it points outside the repository
880
+ let root;
881
+ try { root = realpathSync(String(workdir ?? '')); } catch { root = String(workdir ?? ''); }
882
+
883
+ /** @type {Map<string, string>} realpath → the spelling that owns it */
884
+ const byReal = new Map();
885
+ for (const declared of candidates) {
886
+ let real = null;
887
+ try { real = realpathSync(join(root, declared)); } catch { real = null; }
888
+ let spelling = declared;
889
+ if (real !== null) {
890
+ const rel = relative(root, real);
891
+ // inside the repository, and something git tracks under it
892
+ if (rel !== '' && !rel.startsWith('..') && !isAbsolute(rel) && listed(rel)) spelling = rel;
893
+ }
894
+ const key = real ?? join(root, declared);
895
+ if (!byReal.has(key)) byReal.set(key, spelling);
896
+ }
897
+ return [...byReal.values()];
898
+ }
899
+
900
+ // ── 5. COST ──────────────────────────────────────────────────────────────────
901
+
902
+ /**
903
+ * F6's shape: an unpriced call or round makes the TOTAL unknown, and unknown is
904
+ * reported as null — never as the sum of the priced half wearing a complete coat.
905
+ * `knownUsd` carries the priced half explicitly so nothing is hidden either way.
906
+ *
907
+ * IT IS ALSO THE RUN'S AUDIT OF WHAT WAS SAID, not only of what was spent. Every
908
+ * `add` records the call's RAW output beside its price, through the one
909
+ * scrubbed-persist helper — one list of calls, read two ways. That pairing is
910
+ * structural rather than remembered: run mslhn707 metered a $0.053 scout call
911
+ * and kept not one byte of what it returned, so the malformation that killed the
912
+ * run could not be read afterwards. A cost entry with no raw beside it is that
913
+ * run again.
914
+ */
915
+ /** @param {{ceilingUsd?: number|null}} [o] the OPERATOR's ceiling; `null`/absent is UNBOUNDED */
916
+ export function makeCostBook({ ceilingUsd = null } = {}) {
917
+ /** @type {{label: string, costUsd: number|null, unpricedRounds: number}[]} */
918
+ const entries = [];
919
+ /** @type {ReturnType<typeof scrubRaw>[]} */
920
+ const raws = [];
921
+ return {
922
+ /** @param {string} label @param {any} r a bare-agent run result @param {number} [attempt] */
923
+ add(label, r, attempt = 1) {
924
+ entries.push({ label, ...priceOf(r) });
925
+ raws.push(scrubRaw({ label, attempt, text: r?.text, reason: r?.error ? String(r.error) : null }));
926
+ return entries.at(-1);
927
+ },
928
+ /** @param {{label: string, costUsd: number|null, unpricedRounds: number}[]} calls
929
+ * @param {ReturnType<typeof scrubRaw>[]} [callRaws] */
930
+ absorb(calls, callRaws) { for (const c of calls ?? []) entries.push({ ...c }); for (const w of callRaws ?? []) raws.push({ ...w }); },
931
+ /** the persisted raws, in call order — the same order and the same labels as
932
+ * `report().calls`, because both are readings of one list */
933
+ raws() { return raws.map((w) => ({ ...w })); },
934
+ report() {
935
+ return { ...tallyCalls(entries), calls: entries.map((e) => ({ ...e })) };
936
+ },
937
+ /**
938
+ * THE CEILING'S OWN READING, asked between metered calls. The book is the one
939
+ * place this pipeline's spend accumulates, so it is the one place that can
940
+ * answer honestly — and it answers through the shared predicate rather than a
941
+ * second hand-spelled comparison (`src/text.js`, beside `priceOf`).
942
+ * @returns {'cap-halt'|'pricing-red'|null}
943
+ */
944
+ capStop() { return capStop({ ceilingUsd, ...tallyCalls(entries) }); },
945
+ /** what was set, stated — `null` is "nobody set one", which is UNBOUNDED and
946
+ * a visible operator choice, never a defaulted number */
947
+ ceilingUsd,
948
+ };
949
+ }
950
+
951
+ /**
952
+ * The default model boundary: one bare-agent Loop per call, with the declaration
953
+ * tool wired to END the call the moment it is used. The tool is an OUTPUT
954
+ * CHANNEL, not a step in a conversation, so the round that would acknowledge its
955
+ * result has nothing left to say.
956
+ *
957
+ * MEASURED against the real Loop with a counting provider, both arms: stop-wired
958
+ * = 1 provider round, $0.001; unwired = 2 rounds, $0.002. So the wiring halves
959
+ * the round count of every authoring and revise call — that is the number, not
960
+ * an estimate, and it is why tool mode does not cost double what text mode did.
961
+ *
962
+ * `cacheMessages` (F18) and `maxTokens` (F30) are not optional on this stack.
963
+ * @param {any} provider @param {{system?: string, maxTokens?: number}} [o]
964
+ * @returns {(messages: any[], tools: any[], opts?: any) => Promise<any>}
965
+ */
966
+ export function makeLoopGenerate(provider, { system = AUTHOR_SYSTEM, maxTokens = AUTHOR_MAX_TOKENS } = {}) {
967
+ return async (/** @type {any[]} */ messages, /** @type {any[]} */ tools, /** @type {any} */ opts = {}) => {
968
+ const loop = new Loop({ provider, system });
969
+ const wired = tools.map((t) => ({
970
+ ...t,
971
+ execute: async (/** @type {any} */ a) => { const r = await t.execute(a); loop.stop(); return r; },
972
+ }));
973
+ return loop.run(messages, wired, { cacheMessages: true, maxTokens, ...opts });
974
+ };
975
+ }
976
+
977
+ // ── 6. THE FLOW ──────────────────────────────────────────────────────────────
978
+
979
+ /** key-order-insensitive structural identity, for the early stop @param {any} x */
980
+ function canonical(x) {
981
+ if (x === null || typeof x !== 'object') return JSON.stringify(x) ?? 'null';
982
+ if (Array.isArray(x)) return `[${x.map(canonical).join(',')}]`;
983
+ return `{${Object.keys(x).sort().map((k) => `${JSON.stringify(k)}:${canonical(x[k])}`).join(',')}}`;
984
+ }
985
+
986
+ /**
987
+ * 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
989
+ * under a cap, write nothing — and it never consumes a revision, because a
990
+ * transport fault is not a defect in a close.
991
+ * @param {{messages: any[], generate: Function, mode: 'tool'|'text', retries: number,
992
+ * label: string, book: ReturnType<typeof makeCostBook>, catalogue: Record<string, any>}} o
993
+ */
994
+ async function askDeclaration({ messages, generate, mode, retries, label, book, catalogue }) {
995
+ /** @type {any[]} */
996
+ const convo = [...messages];
997
+ let attempts = 0;
998
+ /** @type {Red|null} */
999
+ let red = null;
1000
+
1001
+ for (let attempt = 0; attempt <= retries; attempt += 1) {
1002
+ // THE CEILING, immediately before the paid call and nowhere else. Sited HERE
1003
+ // rather than in the revise loop because this is where every declaration call
1004
+ // is actually made: the author call, each revise, AND each malformed-emission
1005
+ // retry. The retry axis is the one that matters most — it fires exactly when
1006
+ // the model is malforming, which is the worst moment for a paid loop to be
1007
+ // unbounded. `attempts` is returned as-is so the caller can tell a cap that
1008
+ // tripped before this ask began (0) from one that cut a retry ladder short.
1009
+ const halt = book.capStop();
1010
+ if (halt) return { declaration: null, attempts, convo, raw: '', providerError: null, red: null, budget: halt };
1011
+
1012
+ /** @type {{calls: any[]}} */
1013
+ const box = { calls: [] };
1014
+ const tools = mode === 'tool' ? [declarationTool(box, { catalogue })] : [];
1015
+ const r = await generate(convo, tools, {});
1016
+ attempts += 1;
1017
+ book.add(attempt === 0 ? label : `${label}#${attempt + 1}`, r, attempts);
1018
+ const raw = redactSecrets(String(r?.text ?? ''));
1019
+
1020
+ if (r?.error) {
1021
+ return { declaration: null, attempts, convo, raw, providerError: String(r.error), red: null, budget: null };
1022
+ }
1023
+
1024
+ if (mode === 'tool') {
1025
+ if (box.calls.length === 1) return { declaration: box.calls[0], attempts, convo, raw, providerError: null, red: null, budget: null };
1026
+ red = {
1027
+ code: 'artifact-red',
1028
+ path: DECLARATION_TOOL_NAME,
1029
+ 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`,
1032
+ axis: box.calls.length === 0 ? 'no-declaration-tool-call' : 'multiple-declaration-tool-calls',
1033
+ };
1034
+ } else {
1035
+ // THE FALLBACK PATH — providers without tool mode only. The ONE shipped
1036
+ // parser, never a second one.
1037
+ const { code, red: extractRed } = extractArtifact(raw);
1038
+ 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' }; }
1041
+ } else {
1042
+ red = { code: 'artifact-red', path: 'declaration', detail: `no artifact in the reply: ${extractRed}`, axis: 'no-artifact' };
1043
+ }
1044
+ }
1045
+
1046
+ if (attempt < retries) {
1047
+ const block = renderRejectBlock({ kind: 'artifact', reds: [/** @type {Red} */ (red).detail] });
1048
+ 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}` });
1050
+ }
1051
+ }
1052
+ return { declaration: null, attempts, convo, raw: '', providerError: null, red, budget: null };
1053
+ }
1054
+
1055
+ /**
1056
+ * THE AUTHORING FLOW.
1057
+ *
1058
+ * Everything cheap happens BEFORE any token is spent: an ABSENT survey, an
1059
+ * unreadable listing, an unsupported genre and a genre variable that cannot be
1060
+ * built are all $0 refusals, because a paid call that was always going to be
1061
+ * thrown away is a paid call nobody needed.
1062
+ *
1063
+ * The return is deliberately complete rather than tidy:
1064
+ * - `ok` is true iff a declaration passed validation and was measured;
1065
+ * - `declaration` is the LAST ACCEPTED one, genre-env injected — the exact form
1066
+ * that will run and that the signature will cover;
1067
+ * - `reds` is what the LAST validated iteration said. `ok:true` with a non-empty
1068
+ * `reds` means the final revision was rejected and the previous close stands:
1069
+ * the rejection is reported, never hidden, and never silently shipped either;
1070
+ * - `iterations` carries every intermediate declaration, validation, seed read
1071
+ * and attempt count, because M4 emits them and a human signs against them;
1072
+ * - `raws` carries what every model call actually SAID — the scout's attempts
1073
+ * and the declaration's, scrubbed and bounded through the one persist
1074
+ * helper, in the cost book's own order and under its own labels. It is
1075
+ * present on EVERY path including the $0 preflight refusals, because the
1076
+ * path that spends nothing more is exactly the one whose evidence used to
1077
+ * vanish with the process.
1078
+ *
1079
+ * No spine emission here (M4 wires events).
1080
+ *
1081
+ * @param {{workdir: string, seedRef: string, lang: string, verdictType: string,
1082
+ * answers: Record<string|number, string>, questions?: Record<string|number, string>,
1083
+ * scout: {state: string, facts: any, reason?: string|null, cause?: string|null, calls?: any[], raws?: any[],
1084
+ * budgetStop?: 'cap-halt'|'pricing-red'|null},
1085
+ * listing?: {stop: string|null, files: string[]|null, block: string|null, meta: any}|null,
1086
+ * generate: Function, seedReadFn?: Function, closeCtx?: any,
1087
+ * ceilingUsd?: number|null,
1088
+ * maxRevisions?: number, structureRetries?: number,
1089
+ * structuredMode?: 'tool'|'text', catalogue?: Record<string, any>}} o
1090
+ */
1091
+ export async function authorClose({
1092
+ workdir, seedRef, lang, verdictType,
1093
+ answers, questions = GREEN_QUESTIONS,
1094
+ scout, listing = null,
1095
+ generate, seedReadFn = runSeedReadStages, closeCtx = {},
1096
+ ceilingUsd = null,
1097
+ maxRevisions = MAX_REVISIONS,
1098
+ structureRetries = MAX_STRUCTURE_RETRIES,
1099
+ structuredMode = 'tool',
1100
+ catalogue = KIND_CATALOGUE,
1101
+ }) {
1102
+ // TIGHTEN-ONLY: a caller may lower the ceiling, never raise it — unlimited
1103
+ // revising launders thrash as adaptation (addendum 5). The floor is zero: one
1104
+ // authoring call with no revise round is a legal ask, and a garbage value is
1105
+ // the floor rather than a negative bound the loop never enters.
1106
+ const revisionCap = Math.max(0, Math.min(Math.trunc(Number(maxRevisions)) || 0, MAX_REVISIONS));
1107
+ // TIGHTEN-ONLY on the OTHER axis too, for the identical reason. `structureRetries`
1108
+ // buys provider calls exactly as `maxRevisions` does, and it rode straight through
1109
+ // to `askDeclaration` unclamped — a caller handing 50 got 50 paid malformed-emission
1110
+ // retries above a ceiling this module declares and the suite pins. No new number:
1111
+ // the bound is `MAX_STRUCTURE_RETRIES`, the constant already documented as the
1112
+ // ceiling. Floor 0, exactly as above: `askDeclaration` loops `attempt <= retries`,
1113
+ // so 0 is still ONE attempt with no retry — a legal tighter ask, not an absent one.
1114
+ const retryCap = Math.max(0, Math.min(Math.trunc(Number(structureRetries)) || 0, MAX_STRUCTURE_RETRIES));
1115
+ // THE OPERATOR'S CEILING — NO DEFAULT, and none is invented here. `null` means
1116
+ // nobody set one, which is UNBOUNDED: a defaulted cap is a silent second
1117
+ // ceiling, and the run path already paid for that lesson twice (`maxWallMs` has
1118
+ // no default for exactly this reason, and `shellCapUsd` left off judged against
1119
+ // a number nobody chose). An unbounded authoring run is legal; it is just never
1120
+ // an accident, which is why the driver PRINTS it.
1121
+ const book = makeCostBook({ ceilingUsd });
1122
+ // The scout's calls AND its raws, together. The raws matter most on the path
1123
+ // that spends nothing more: run mslhn707 refused at the $0 preflight below,
1124
+ // and the survey text that would have said WHY died with the process.
1125
+ book.absorb(scout?.calls ?? [], scout?.raws ?? []);
1126
+ /** @type {any} */
1127
+ const base = {
1128
+ ok: false, declaration: null, seedRead: null, iterations: [], reds: [], cost: book.report(),
1129
+ raws: book.raws(),
1130
+ facts: null, listing: null, genreEnv: null, finalFrom: null, stop: null, revisions: 0,
1131
+ };
1132
+ const refuse = (/** @type {Red[]} */ reds, /** @type {string} */ stop) => ({ ...base, reds, stop, cost: book.report(), raws: book.raws() });
1133
+
1134
+ // ── $0 preflight ──────────────────────────────────────────────────────────
1135
+ // F59, in the shape that bites: `{}` is the scout not completing, never "no
1136
+ // special facts are needed". This one lands in a SIGNED artefact.
1137
+ if (scout?.state !== 'PRESENT' || !scout.facts || typeof scout.facts !== 'object'
1138
+ || Object.keys(scout.facts).length === 0) {
1139
+ // A SURVEY THE CEILING STOPPED IS NOT A SURVEY THAT FAILED. The scout carries
1140
+ // its own governance stop, and when it is set that is the honest name for why
1141
+ // the facts are missing — the same rule W-2 settled for the clock (a time-stop
1142
+ // is NAMED a time-stop). `scout-absent` sends the operator to the scout; a
1143
+ // cap-halt sends them to the number they set, which is the one that moves.
1144
+ if (scout?.budgetStop) {
1145
+ // A CONCURRENT CAUSE IS CONCEDED, NEVER DENIED. The ceiling can refuse the
1146
+ // reserved recovery round for a call that ALSO died on transport, and both
1147
+ // are then true at once. "not on anything it read" would deny the very
1148
+ // error the same sentence goes on to quote, and this red carries
1149
+ // `path: budgetUsd` — so the operator is steered to raise a ceiling into a
1150
+ // dead socket, which is the one lever that cannot repair it. The stop
1151
+ // itself does not move: the ceiling did bind, and naming the second cause
1152
+ // is attribution, not routing.
1153
+ //
1154
+ // The transport death is not the only second cause. The ceiling also
1155
+ // refuses the recovery round behind a SHORT/EMPTY reply, and the retry
1156
+ // behind an EMPTY/UNPARSEABLE one — every cause except NOT_FUNDED means a
1157
+ // real call produced the reason being quoted, so "not on anything it
1158
+ // read" is honest ONLY when no call was ever made.
1159
+ const alsoDied = scout?.cause === SURVEY_CAUSES.CALL_FAILED;
1160
+ const alsoIncomplete = !alsoDied && scout?.cause != null && scout.cause !== SURVEY_CAUSES.NOT_FUNDED;
1161
+ return refuse([{
1162
+ code: scout.budgetStop,
1163
+ path: 'budgetUsd',
1164
+ detail: alsoDied
1165
+ ? 'the authoring ceiling bound here AND the survey call itself failed — both are true, and raising the '
1166
+ + `ceiling will not repair the second: ${redactSecrets(String(scout.reason ?? ''))}`
1167
+ : alsoIncomplete
1168
+ ? 'the authoring ceiling bound here AND the survey came back incomplete — both are true: the ceiling '
1169
+ + `refused the round that could have repaired it: ${redactSecrets(String(scout.reason ?? ''))}`
1170
+ : `the survey stopped on the authoring ceiling, not on anything it read: ${redactSecrets(String(scout.reason ?? ''))}`,
1171
+ }], scout.budgetStop);
1172
+ }
1173
+ return refuse([{
1174
+ code: 'scout-absent',
1175
+ path: 'scout',
1176
+ // `scout.reason` is half survey bookkeeping and half the scout's own error
1177
+ // string (its `meta.error`), so provider prose reaches a KEPT red here —
1178
+ // scrubbed at the emission boundary like every other channel that quotes
1179
+ // something this module does not own
1180
+ detail: `the survey is ${scout?.state ?? 'missing'}${scout?.reason ? ` (${redactSecrets(scout.reason)})` : ''} — an absent facts `
1181
+ + 'object is never "no special facts are needed", and there is nothing to author from',
1182
+ }], 'precheck');
1183
+ }
1184
+ const facts = scout.facts;
1185
+ base.facts = facts;
1186
+
1187
+ // THE PICKED CLASS, checked at $0 before the language: the battery attaches to
1188
+ // the CLASS (PRD v1.57 §2), so a class v1 does not build has no battery to
1189
+ // attach — and a close authored without the guards it cannot supply is exactly
1190
+ // what D5 exists to make impossible. `verb` rides as a structured field so the
1191
+ // caller can carry this to the counted admission path rather than re-deriving
1192
+ // the demand from prose.
1193
+ if (!LIVE_CLASSES.includes(String(verdictType))) {
1194
+ const known = VERDICT_CLASSES.includes(String(verdictType));
1195
+ return refuse([{
1196
+ code: 'class-unsupported',
1197
+ path: 'verdictType',
1198
+ verb: String(verdictType),
1199
+ lib: 'bareloop',
1200
+ detail: known
1201
+ ? `"${verdictType}" is a named but LOCKED verdict class — v1 admits ${LIVE_CLASSES.join(', ')} only, and a `
1202
+ + 'class with no guard battery must refuse rather than author a close without the guards it cannot supply'
1203
+ : `"${verdictType}" is not a verdict class — the menu is ${VERDICT_CLASSES.join(' | ')}, and nothing derives `
1204
+ + 'a class from the answers',
1205
+ }], 'precheck');
1206
+ }
1207
+
1208
+ if (!GENRE_LANGUAGES.includes(lang)) {
1209
+ return refuse([{
1210
+ code: 'genre-unsupported',
1211
+ path: 'lang',
1212
+ verb: 'genre-other',
1213
+ lib: 'bareloop',
1214
+ detail: `no TYPES genre data for language "${lang}" — one of ${GENRE_LANGUAGES.join(', ')}. A genre with no `
1215
+ + 'guard battery must refuse, never author a close without the guards it cannot supply',
1216
+ }], 'precheck');
1217
+ }
1218
+
1219
+ const seeds = listing ?? await buildSeedListing({
1220
+ workdir, seedRef, sourcePaths: facts.sourcePaths, testPaths: facts.testPaths,
1221
+ });
1222
+ const seedFiles = Array.isArray(seeds.files) ? seeds.files : null;
1223
+ if (seeds.stop !== null || seedFiles === null || seedFiles.length === 0) {
1224
+ return refuse([{
1225
+ code: seeds.stop ? 'listing-unreadable' : 'listing-absent',
1226
+ path: 'listing',
1227
+ // `stop` is git's own stderr (kinds.js builds it from the subprocess);
1228
+ // scrubbed HERE, at the emission boundary, because this red LEAVES the flow
1229
+ // and is kept — M1 never scrubs, the caller does
1230
+ detail: seeds.stop != null
1231
+ ? redactSecrets(seeds.stop)
1232
+ : 'the seed listing is empty — without it nothing can tell a real path from an invented one',
1233
+ }], 'precheck');
1234
+ }
1235
+ base.facts = facts;
1236
+ base.listing = seeds;
1237
+
1238
+ const guards = classGuards({ verdictType, lang });
1239
+ const ownedEnvNames = genreOwnedEnvNames(lang);
1240
+ // the source prefixes the genre's environment is built from, filtered against
1241
+ // the REAL tree — a prefix that names nothing would build a variable pointing
1242
+ // at nothing, and a symlink plus the directory behind it would name ONE tree
1243
+ // twice (see resolveSourcePrefixes: that is a FATAL mypy exit, not a warning)
1244
+ const sourcePrefixes = resolveSourcePrefixes({ workdir, sourcePaths: facts.sourcePaths, seedFiles });
1245
+ const envProbe = applyGenreEnv({ stages: [] }, lang, { sourcePrefixes, catalogue });
1246
+ base.genreEnv = { applied: envProbe.applied, owned: ownedEnvNames, missing: envProbe.missing, dropped: [] };
1247
+ if (envProbe.reds.length) return refuse(envProbe.reds, 'precheck');
1248
+
1249
+ // ── the grounded loop ─────────────────────────────────────────────────────
1250
+ const prompt = authorPrompt({
1251
+ answers, questions, facts, listingBlock: /** @type {string} */ (seeds.block),
1252
+ lang, verdictType, guards, ownedEnvNames, mode: structuredMode, catalogue,
1253
+ });
1254
+ /** @type {any[]} */
1255
+ let messages = [{ role: 'user', content: prompt }];
1256
+ /** @type {any[]} */
1257
+ const iterations = [];
1258
+ /** @type {any} */
1259
+ let previous = null;
1260
+ /** @type {any} */
1261
+ let accepted = null;
1262
+ /** @type {any[]|null} */
1263
+ let acceptedSeedRead = null;
1264
+ /** @type {string|null} */
1265
+ let finalFrom = null;
1266
+ /** @type {{ok: boolean, reds: Red[]}|null} */
1267
+ let lastValidation = null;
1268
+ /** @type {Red[]} */
1269
+ let fatal = [];
1270
+ let stop = 'max-revisions';
1271
+ /** @type {{stage: string, name: string}[]} */
1272
+ let droppedEnv = [];
1273
+
1274
+ /**
1275
+ * THE GOVERNANCE STOP'S OWN RED. It names the CAP and the SPEND, because the
1276
+ * operator's next move is a NUMBER — resume-to-cap is the shipped lever (the
1277
+ * stop IS the checkpoint), and a stop that reports neither side of the
1278
+ * comparison cannot be acted on. It is a governance stop, never an error: the
1279
+ * partial artifacts stay exactly where they are, nothing retries, and nothing
1280
+ * about the close itself is being judged here.
1281
+ * @param {'cap-halt'|'pricing-red'} code
1282
+ * @returns {Red}
1283
+ */
1284
+ const capRed = (code) => {
1285
+ const c = book.report();
1286
+ const spent = `$${c.knownUsd.toFixed(6)} across ${c.calls.length} metered call(s)`;
1287
+ return code === 'pricing-red'
1288
+ ? {
1289
+ code,
1290
+ path: 'budgetUsd',
1291
+ detail: `the authoring ceiling cannot be enforced: ${c.nullCostCalls} unpriced call(s) and ${c.unpricedRounds} `
1292
+ + `unpriced round(s) make the total unknown, against a $${ceilingUsd} ceiling (${spent} is the KNOWN half). `
1293
+ + 'Unpriced is never free (F6) — a cap that cannot see the spend cannot govern it, so the run stops rather '
1294
+ + 'than spending against a floor that reads as exact.',
1295
+ }
1296
+ : {
1297
+ code,
1298
+ path: 'budgetUsd',
1299
+ detail: `the authoring ceiling is spent: ${spent} against a $${ceilingUsd} ceiling. The next call was never `
1300
+ + 'made — the cap binds BETWEEN calls, so nothing was cut off mid-flight and everything paid for is on '
1301
+ + 'disk. The stop IS the checkpoint: raise the ceiling and re-run, or read what is here and stop.',
1302
+ };
1303
+ };
1304
+
1305
+ const seedTrees = makeSeedTrees();
1306
+ try {
1307
+ for (let i = 0; i <= revisionCap; i += 1) {
1308
+ const label = i === 0 ? 'author' : `revise-${i}`;
1309
+ const ask = await askDeclaration({ messages, generate, mode: structuredMode, retries: retryCap, label, book, catalogue });
1310
+ messages = ask.convo;
1311
+
1312
+ // The cap tripped BEFORE this ask made any call at all, so there is no
1313
+ // iteration to record: an iteration row for a call that never happened is
1314
+ // the blind-instrument class one field wide (`ralph` already pays for the
1315
+ // sibling of this — a governance stop narrated for a loop it never
1316
+ // entered). A ladder cut SHORT is the other case and falls through: those
1317
+ // calls did happen, so the row stays and the stop is read after it.
1318
+ if (ask.budget && ask.attempts === 0) {
1319
+ fatal = [capRed(ask.budget)];
1320
+ stop = ask.budget;
1321
+ break;
1322
+ }
1323
+ /** @type {any} */
1324
+ const iter = {
1325
+ call: label, attempts: ask.attempts, declaration: ask.declaration ?? null,
1326
+ artifactRed: ask.red ?? null, providerError: ask.providerError ?? null,
1327
+ validation: null, seedRead: null, reviseTurn: null,
1328
+ };
1329
+ iterations.push(iter);
1330
+
1331
+ // A retry ladder cut short by the cap. The money stop OUTRANKS the artifact
1332
+ // red that was in flight: the artifact red says the model malformed, the cap
1333
+ // says we stopped paying to find out — and only the second one is why the
1334
+ // loop ended. Reporting the first would send the operator to the model when
1335
+ // the fact is the wallet.
1336
+ if (ask.budget) {
1337
+ fatal = [capRed(ask.budget)];
1338
+ stop = ask.budget;
1339
+ break;
1340
+ }
1341
+
1342
+ if (ask.providerError) {
1343
+ // a transport failure is a casualty, never evidence about the model — and
1344
+ // the transport's own prose (a proxy URL, an auth echo) is scrubbed on the
1345
+ // way into a red the caller keeps
1346
+ fatal = [{ code: 'provider-red', path: label, detail: redactSecrets(ask.providerError) }];
1347
+ stop = 'provider-red';
1348
+ break;
1349
+ }
1350
+ if (!ask.declaration) {
1351
+ fatal = [/** @type {Red} */ (ask.red)];
1352
+ stop = 'artifact-red';
1353
+ break;
1354
+ }
1355
+
1356
+ // early stop: an unchanged declaration has the previous iteration's
1357
+ // measurements by definition — re-executing it buys nothing and costs a
1358
+ // full close
1359
+ if (previous && canonical(ask.declaration) === canonical(previous)) {
1360
+ iter.validation = '(identical declaration — see the previous iteration)';
1361
+ iter.seedRead = '(identical declaration — see the previous iteration)';
1362
+ stop = 'unchanged';
1363
+ break;
1364
+ }
1365
+ previous = ask.declaration;
1366
+
1367
+ const v = validateDeclaration(ask.declaration, { catalogue, listing: seedFiles, guards, envOwned: ownedEnvNames, verdictType });
1368
+ // SCRUBBED HERE, once, where the reds enter this module's records — the same
1369
+ // boundary rule `renderRejectBlock` already states, applied to the OTHER
1370
+ // channel they travel down. A validation red quotes what the model declared
1371
+ // verbatim (`cmd-denied` carries the whole `cmd`, the genre-env branch carries
1372
+ // the declared value, the listing rule carries the path), and these copies are
1373
+ // PERSISTED: they ride `iterations[].validation` and this function's returned
1374
+ // `reds` into `authored.json` and into the spine (`emit('job-red', …)`), which
1375
+ // is append-only — a record that captures a key captures it forever. The
1376
+ // model-facing render was masked and the persisted twin was not.
1377
+ const vReds = v.reds.map(scrubRed);
1378
+ iter.validation = { ok: v.ok, reds: vReds, scoped: v.scoped };
1379
+ lastValidation = { ok: v.ok, reds: vReds };
1380
+
1381
+ /** @type {string} */
1382
+ let measured;
1383
+ if (!v.ok) {
1384
+ // a validation red IS a measurement of the declaration, so it rides the
1385
+ // same channel and counts as a revision (addendum 5)
1386
+ measured = renderRejectBlock({ kind: 'validation', reds: vReds });
1387
+ } else {
1388
+ // what gets MEASURED is what will RUN: the resolved declaration with the
1389
+ // genre's environment already injected
1390
+ const injected = applyGenreEnv(v.declaration, lang, { sourcePrefixes, catalogue });
1391
+ droppedEnv = injected.dropped;
1392
+ // `gapKeep` defaults to EMPTY rather than being left undefined: M1
1393
+ // prefixes every gap line with it verbatim, so an unset one prints the
1394
+ // word "undefined" on every failure line the model is asked to read
1395
+ const rows = await seedReadFn(injected.declaration, { gapKeep: '', ...closeCtx, workdir, seedRef, seedTrees });
1396
+ iter.seedRead = rows;
1397
+ accepted = injected.declaration;
1398
+ acceptedSeedRead = rows;
1399
+ finalFrom = label;
1400
+ measured = renderSeedReadBlock(rows);
1401
+ }
1402
+
1403
+ if (i === revisionCap) { stop = 'max-revisions'; break; }
1404
+
1405
+ const turn = buildReviseTurn(measured);
1406
+ // A TRIPWIRE, not behaviour: it can only fire once something else is
1407
+ // already broken, so it is the one guard in this module the mutation
1408
+ // battery cannot kill — removing it changes no output today. It is kept
1409
+ // deliberately, and what the suite pins instead is the invariant it
1410
+ // protects: the bytes leaving this loop are exactly
1411
+ // `buildReviseTurn(renderSeedReadBlock(rows))`, so a future edit that
1412
+ // appends one helpful sentence here reds the suite either way.
1413
+ assertReviseTurn(turn, measured);
1414
+ iter.reviseTurn = { bytes: Buffer.byteLength(turn) };
1415
+ messages = [...messages, { role: 'assistant', content: ask.raw.trim() || '(the reply carried no text)' }, { role: 'user', content: turn }];
1416
+ }
1417
+ } finally {
1418
+ await seedTrees.cleanup();
1419
+ }
1420
+
1421
+ const reds = fatal.length ? fatal : (lastValidation && !lastValidation.ok ? lastValidation.reds : []);
1422
+ return {
1423
+ // `ok` means A SIGNABLE CLOSE EXISTS, and nothing more. A dead socket on a
1424
+ // LATER revise call does not invalidate a close that was already validated
1425
+ // and already measured — a casualty is never evidence (F45), and throwing a
1426
+ // paid, measured close away over a transport fault is the wrong direction.
1427
+ // Why the loop ended is never hidden: `stop` names it and `reds` carries it.
1428
+ ok: accepted !== null,
1429
+ declaration: accepted,
1430
+ seedRead: acceptedSeedRead,
1431
+ iterations,
1432
+ reds,
1433
+ cost: book.report(),
1434
+ // WHAT THE MODEL ACTUALLY SAID, every call of it, on the run's own audit
1435
+ // trail — the scout's attempts and the declaration's, in call order, under
1436
+ // the same labels the cost book meters. `iterations` records what each turn
1437
+ // MEANT (declaration, validation, seed read); this records what it SAID, and
1438
+ // a malformation is only ever visible in the second.
1439
+ raws: book.raws(),
1440
+ facts,
1441
+ listing: seeds,
1442
+ genreEnv: { ...base.genreEnv, dropped: droppedEnv },
1443
+ finalFrom,
1444
+ stop,
1445
+ revisions: Math.max(0, iterations.length - 1),
1446
+ };
1447
+ }