bareloop 0.7.0 → 0.9.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,1280 @@
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,
82
+ validateDeclaration, envCapableKind, VERDICT_CLASSES, LIVE_CLASSES,
83
+ } from './authoring.js';
84
+ import { buildSeedListing, cleanEntry } from './authorscout.js';
85
+ import { extractArtifact, priceOf, scrubRaw } 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
+ /** The three laws. Stated to the author; enforced, where checkable, by M2. */
500
+ export function lawsBlock() {
501
+ return `THE LAWS YOUR DECLARATION MUST OBEY
502
+
503
+ 1. ONE POPULATION PER STAGE.
504
+ A stage name owns exactly one population of things being counted or checked. Two structurally
505
+ different counts never share a stage name, because their results land in one trend series and a
506
+ run that merely swapped one wall for another then reads as progress. Every counting stage over a
507
+ job that touches only part of the tree must say WHICH population it counts: its scope carries
508
+ includePrefixes or excludePrefixes. A scope object with neither names nothing.
509
+
510
+ 2. FIRST RED WINS — order cheap before expensive.
511
+ Stages run in the order you declare them and the first red ends the judgement. Put the cheap,
512
+ fast, structural stages first so they shield the slow ones.
513
+
514
+ 3. MECHANICAL FIRST.
515
+ Every assertion that CAN be a command IS a command. Nothing is left to judgement, to prose, or to
516
+ a person. If you cannot express something with the kinds above, leave it out and say so in your
517
+ notes — do not approximate it with a stage that means something else.
518
+
519
+ 4. EVERY PATH IS SELECTED, NEVER INVENTED.
520
+ Any path you write down must appear in the seed file listing above. A file is not called what it
521
+ sounds like it should be called.`;
522
+ }
523
+
524
+ /**
525
+ * The whole authoring prompt, as one string.
526
+ *
527
+ * Two people fed this brief and neither of them is the model: a person who is not
528
+ * a software engineer answered an interview, and a read-only survey produced the
529
+ * facts. The prompt says so, because round 1 measured what happens when a model
530
+ * treats a lay answer as a specification.
531
+ *
532
+ * `verdictType` is REQUIRED and has no default. It is the user's own answer and
533
+ * the one thing that tells the composer what DONE is allowed to mean here; a
534
+ * defaulted class would be the prompt answering a question the user was asked.
535
+ *
536
+ * @param {{answers: Record<string|number, string>, questions?: Record<string|number, string>,
537
+ * facts: any, listingBlock: string, lang: string, verdictType: string,
538
+ * guards: {name: string, kind: string, params: Record<string, any>, fill: string[]}[],
539
+ * ownedEnvNames?: string[], mode?: 'tool'|'text', catalogue?: Record<string, any>}} o
540
+ */
541
+ export function authorPrompt({ answers, questions = GREEN_QUESTIONS, facts, listingBlock, lang, verdictType, guards, ownedEnvNames = [], mode = 'tool', catalogue = KIND_CATALOGUE }) {
542
+ const statement = CLASS_STATEMENTS[String(verdictType)] ?? null;
543
+ if (statement === null) {
544
+ throw new Error(`[authorflow] the authoring prompt needs the verdict class the user picked, and "${verdictType}" `
545
+ + `composes nothing here — v1 composes ${Object.entries(CLASS_STATEMENTS).filter(([, v]) => v).map(([k]) => k).join(', ')} only`);
546
+ }
547
+ const interview = Object.entries(questions)
548
+ .map(([n, q]) => `Q${n}. ${q}\nA${n}. ${answers?.[n] ?? '(no answer given)'}`)
549
+ .join('\n\n');
550
+
551
+ const role = `You are composing the close for one job.
552
+
553
+ The close is what decides whether a run of this job came back DONE. It runs after the work is
554
+ finished, it is the only truth, and nothing else can overrule it. It is a list of STAGES. Each
555
+ stage names a kind from the catalogue below and fills that kind's parameters. Stages run in the
556
+ order you declare them.
557
+
558
+ Two people fed you this brief and neither of them is you:
559
+ - the person who wants the job done answered an interview, below, in their own words. They are
560
+ not a software engineer. They did not name commands, thresholds or patterns, and you must not
561
+ assume they know what any of those would be;
562
+ - a read-only survey of their repository produced the facts object, below. It is the only thing
563
+ you know about the repository. If a fact you need is not in it, say so in your notes rather
564
+ than inventing it.`;
565
+
566
+ // The guards arrive FULLY PARAMETERISED — the battery and its scope are genre
567
+ // property (addendum 6). Only a declared `fill` slot is the model's, and it is
568
+ // marked. Round 3's arm A narrowed a guard's scan and called it stricter.
569
+ const guardJson = guards.map((g) => ({
570
+ name: g.name,
571
+ kind: g.kind,
572
+ params: { ...g.params, ...Object.fromEntries(g.fill.map((f) => [f, FILL_MARKER])) },
573
+ }));
574
+
575
+ const envLine = ownedEnvNames.length
576
+ ? `\n\nThis job's genre sets ${ownedEnvNames.join(', ')} for you, from facts you were not given. `
577
+ + 'Do not declare any of those variables yourself — a declaration that sets one is rejected.'
578
+ : '';
579
+
580
+ const output = mode === 'tool'
581
+ ? `HOW TO ANSWER
582
+
583
+ Call the ${DECLARATION_TOOL_NAME} tool exactly once. Its arguments ARE the declaration: an ordered
584
+ "stages" array, plus "notes" for anything you could not express or any fact you needed and did not
585
+ have. Do not write the declaration as text.
586
+
587
+ Stage names are unique. Every parameter name comes from the catalogue and no other. No parameter is
588
+ left as a placeholder. You never write a threshold number you measured yourself, because you cannot
589
+ measure anything.`
590
+ : `HOW TO ANSWER
591
+
592
+ A single JSON object and nothing else: an ordered "stages" array, plus "notes" for anything you
593
+ could not express or any fact you needed and did not have.
594
+
595
+ Stage names are unique. Every parameter name comes from the catalogue and no other. No parameter is
596
+ left as a placeholder. You never write a threshold number you measured yourself, because you cannot
597
+ measure anything.`;
598
+
599
+ return [
600
+ role,
601
+ // WHERE GENRE UNDERSTANDING LIVES NOW (PRD v1.57 §2). The interview no longer
602
+ // asks what KIND of job this is; the person answered what DONE means, and the
603
+ // composer reads that plus the catalogue and works the rest out.
604
+ `WHAT THE PERSON DECLARED "DONE" TO MEAN — their answer, not yours\n\n${statement}`,
605
+ `THE INTERVIEW — the person's own words\n\n${interview}`,
606
+ `THE FACTS OBJECT — from a read-only survey of the repository\n\n${JSON.stringify(facts, null, 2)}`
607
+ + (listingBlock ? `\n\n${listingBlock}` : ''),
608
+ catalogueBlock(catalogue),
609
+ lawsBlock(),
610
+ `THE GENRE TEMPLATE — fixed by the job's genre (${lang}), not yours to decide\n\n`
611
+ + 'This job\'s genre carries a fixed close SHAPE. The following policy is law for your declaration. It names\n'
612
+ + 'no command, no path, no environment value and no pattern for this repository — finding those is your work,\n'
613
+ + `from the facts object and the interview:\n\n${TYPES_GENRE_TEMPLATE}`,
614
+ 'MANDATORY GUARDS — already placed and already parameterised\n\n'
615
+ + '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
+ + 'already in your declaration, with their pattern battery and extensions already filled in — those are genre\n'
617
+ + `property and you may not change, drop, rename, or re-kind them. The ONLY thing you fill in is each\n`
618
+ + `"${FILL_MARKER}" slot, from the facts object and the interview:\n\n${JSON.stringify(guardJson, null, 2)}\n\n`
619
+ + 'Place them where the ordering law says they belong, and author the remaining stages around them.'
620
+ + envLine,
621
+ output,
622
+ ].join('\n\n---\n\n');
623
+ }
624
+
625
+ // ── 3. THE MEASURED BLOCKS ───────────────────────────────────────────────────
626
+
627
+ /** F6, at the rendering boundary: an absent number is unknown, never 0. */
628
+ const show = (/** @type {any} */ v) => (v === null || v === undefined ? 'unknown' : String(v));
629
+
630
+ /**
631
+ * The seed read, as the model sees it: mechanical labels, no editorial. Per-stage
632
+ * gap capped with the trim ANNOUNCED (F28 — a bound that eats the failure names
633
+ * deletes exactly the mechanical detail that converts).
634
+ *
635
+ * SCRUBBED at the emission boundary. What this block renders is raw subprocess
636
+ * output (gap lines, an instrument stop carrying a command's stderr) and it
637
+ * becomes the next USER TURN sent to the provider — the surface most likely to
638
+ * carry a real credential, crossing the network. It goes through the ONE shared
639
+ * redactor, the same one `askDeclaration` puts the model's own text through and
640
+ * the same one `declaredclose`/`authorjob` put these very fields through; never
641
+ * a second inventory. Applied to the whole rendered block rather than one field,
642
+ * so a line added here is scrubbed by construction.
643
+ * @param {any[]} rows M1 `StageResult[]`
644
+ * @param {{gapCap?: number}} [o]
645
+ */
646
+ export function renderSeedReadBlock(rows, { gapCap = REVISE_GAP_CAP } = {}) {
647
+ const lines = ['MEASURED SEED-READ — every stage of your declaration was executed against the repository at the seed:'];
648
+ for (const r of rows ?? []) {
649
+ lines.push('', `stage "${r.stage}" [${r.kind}]`);
650
+ lines.push(` verdict: ${r.verdict} · exit code: ${show(r.exitCode)}`);
651
+ if (r.value !== null && r.value !== undefined) {
652
+ lines.push(` value: ${show(r.value)} · baseline: ${show(r.baseline)}`
653
+ + `${r.baselineSource ? ` (baseline ${r.baselineSource})` : ''}`);
654
+ }
655
+ if (r.detail?.stop) lines.push(` instrument-stop: ${r.detail.stop}`);
656
+ const gap = r.gapLines ?? [];
657
+ if (gap.length) {
658
+ lines.push(' gap:');
659
+ for (const g of gap.slice(0, gapCap)) lines.push(` ${g}`);
660
+ if (gap.length > gapCap) {
661
+ lines.push(` [gap trimmed: ${gap.length - gapCap} of ${gap.length} lines withheld — the cap is ${gapCap} per stage]`);
662
+ }
663
+ }
664
+ }
665
+ return redactSecrets(lines.join('\n'));
666
+ }
667
+
668
+ /**
669
+ * The measured block when the declaration never reached execution. Both cases are
670
+ * machine outputs of the pipeline, never editorial prose about the model's work.
671
+ *
672
+ * SCRUBBED at the emission boundary, for the SAME reason `renderSeedReadBlock`
673
+ * is and through the SAME one redactor. The channel here is different and just
674
+ * as real: a validation red QUOTES WHAT THE MODEL DECLARED, verbatim — the
675
+ * genre-env branch echoes the expected and the declared value, the deny floor
676
+ * echoes the whole `cmd`, the listing rule echoes the path — and this block
677
+ * becomes the next USER TURN sent to the provider. A declared value shaped like
678
+ * a credential crossed unmasked before this line existed. Applied to the whole
679
+ * rendered block rather than one field, so a red added to the validator is
680
+ * scrubbed by construction rather than by remembering.
681
+ * @param {{kind: 'validation'|'artifact', reds: (Red|string)[], cap?: number}} o
682
+ */
683
+ export function renderRejectBlock({ kind, reds, cap = REVISE_RED_CAP }) {
684
+ const head = kind === 'validation'
685
+ ? 'MEASURED VALIDATION — your declaration was rejected by the validator before any stage could run:'
686
+ : 'MEASURED PARSE — your reply could not be read as one declaration:';
687
+ const rendered = (reds ?? []).map((r) => (typeof r === 'string' ? r : `${r.code} at ${r.path || '(root)'}: ${r.detail}`));
688
+ const shown = rendered.slice(0, cap);
689
+ const tail = rendered.length > shown.length
690
+ ? [` [${rendered.length - shown.length} of ${rendered.length} reds withheld — the cap is ${cap}]`]
691
+ : [];
692
+ return redactSecrets([head, ...shown.map((r) => ` ${r}`), ...tail].join('\n'));
693
+ }
694
+
695
+ /** A revise turn is EXACTLY the measured block, a blank line, and the instruction. */
696
+ export function buildReviseTurn(/** @type {string} */ measuredBlock) {
697
+ return `${measuredBlock}\n\n${REVISE_INSTRUCTION}`;
698
+ }
699
+
700
+ /**
701
+ * The structural guarantee that no other prose can enter a revise turn. Without
702
+ * it "the feedback is measurement only" is a promise; with it, it is a fact that
703
+ * fails the build the day someone appends a helpful sentence.
704
+ * @param {string} turn @param {string} measuredBlock
705
+ */
706
+ export function assertReviseTurn(turn, measuredBlock) {
707
+ if (!/^MEASURED (SEED-READ|VALIDATION|PARSE) —/.test(measuredBlock)) {
708
+ throw new Error('REVISE DRIFT: the measured block does not open with a mechanical header');
709
+ }
710
+ if (turn !== `${measuredBlock}\n\n${REVISE_INSTRUCTION}`) {
711
+ throw new Error('REVISE DRIFT: the turn is not measured-block + the frozen instruction, exactly');
712
+ }
713
+ return { ok: true, bytes: Buffer.byteLength(turn) };
714
+ }
715
+
716
+ // ── 4. THE GENRE'S ENVIRONMENT ───────────────────────────────────────────────
717
+
718
+ /** Which kinds can carry an environment at all — read off the catalogue, never a
719
+ * list, and read through M2's OWN predicate rather than a second copy of it.
720
+ * The validator's completeness rule demands the variable on exactly the stages
721
+ * this injector puts it on, so the two must be one function: a drift either way
722
+ * leaves stages injected-but-unchecked or checked-but-never-injected. */
723
+ const envCapable = envCapableKind;
724
+
725
+ /**
726
+ * Apply the genre's environment to a declaration, mechanically.
727
+ *
728
+ * Three rules, and each one is a finding:
729
+ * - it lands on every stage that SPAWNS something (env-capable by the
730
+ * catalogue) and nowhere else — a stage that runs no process has no
731
+ * environment to set;
732
+ * - a model-authored copy of a genre-owned name is DROPPED and ANNOUNCED. M2's
733
+ * validator already reds that, so this is defence in depth — but the failure
734
+ * direction matters: silently keeping the model's guess is how round 2's arm
735
+ * B graded an unedited checkout;
736
+ * - a genre variable this patient cannot supply is a RED, not an omission. It
737
+ * moves no seed number, so no seed-verdict read can ever see it missing, and
738
+ * the artefact it would be missing from is SIGNED.
739
+ *
740
+ * The input declaration is never mutated: the model's own copy has to stay what
741
+ * the model wrote, or the next turn's validator reds it for our injection.
742
+ * @param {any} declaration
743
+ * @param {string} lang
744
+ * @param {{sourcePrefixes?: string[], catalogue?: Record<string, any>}} [o]
745
+ * @returns {{declaration: any, applied: Record<string, string>, dropped: {stage: string, name: string}[],
746
+ * missing: string[], reds: Red[]}}
747
+ */
748
+ export function applyGenreEnv(declaration, lang, { sourcePrefixes = [], catalogue = KIND_CATALOGUE } = {}) {
749
+ const applied = genreEnv(lang, { sourcePrefixes });
750
+ const owned = genreOwnedEnvNames(lang);
751
+ const missing = owned.filter((n) => !Object.hasOwn(applied, n));
752
+ /** @type {{stage: string, name: string}[]} */
753
+ const dropped = [];
754
+ /** @type {Red[]} */
755
+ const reds = missing.map((name) => ({
756
+ code: 'genre-env-unavailable',
757
+ path: `genre.env.${name}`,
758
+ detail: `the ${lang} genre owns ${name} and this patient supplies nothing to build it from — it is a fact `
759
+ + 'the interview cannot give and the repository does not state, it moves no seed number, and a close signed '
760
+ + 'without it grades a different checkout',
761
+ name,
762
+ }));
763
+
764
+ const out = structuredClone(declaration);
765
+ for (const s of out?.stages ?? []) {
766
+ if (!s || typeof s !== 'object' || !envCapable(s.kind, catalogue)) continue;
767
+ if (!s.params || typeof s.params !== 'object') continue;
768
+ /** @type {Record<string, string>} */
769
+ const env = { ...(s.params.env ?? {}) };
770
+ const conflicts = owned.filter((n) => Object.hasOwn(env, n));
771
+ // touch nothing when there is nothing to do: a declaration is a SIGNED
772
+ // artefact, and rewriting a stage we had no reason to change makes the
773
+ // signed form differ from what the model wrote for no stated reason
774
+ if (conflicts.length === 0 && Object.keys(applied).length === 0) continue;
775
+ for (const name of conflicts) { dropped.push({ stage: s.name, name }); delete env[name]; }
776
+ Object.assign(env, applied);
777
+ if (Object.keys(env).length) s.params.env = env;
778
+ else delete s.params.env;
779
+ }
780
+ return { declaration: out, applied, dropped, missing, reds };
781
+ }
782
+
783
+ /**
784
+ * The prefixes `genreEnv` JOINS into the genre's variable, resolved against the
785
+ * real tree. Two filters, and the second one was paid for on a live patient.
786
+ *
787
+ * EXISTENCE (unchanged): a prefix that names nothing at the seed would build a
788
+ * variable pointing at nothing, and the grounded validator refuses it anyway
789
+ * (`ungroundedGenreEnv` checks every ELEMENT against the seed listing).
790
+ *
791
+ * IDENTITY (new): existence alone reads a SYMLINK and the directory behind it as
792
+ * two prefixes. On aurora, 11 entries under `src` resolve into the per-package
793
+ * `packages/<pkg>/src` trees, so the joined `MYPYPATH` named one tree twice
794
+ * under two spellings and mypy died FATALLY — exit 2, "shadows library module",
795
+ * zero output — which the close reads as a broken instrument on a perfectly
796
+ * healthy patient. The realpath-deduped control produced the identical clean
797
+ * reading as the known-good invocation.
798
+ *
799
+ * WHICH SPELLING SURVIVES is not cosmetic. The REAL path wins, because that is
800
+ * the one the checker itself resolves to; the symlink spelling is kept only when
801
+ * the real one cannot be used. And a spelling is only ever emitted when it
802
+ * SELECTS from the seed listing, because the arbiter records this value in a
803
+ * SIGNED envelope and re-validates it GROUNDED — a value the grounded validator
804
+ * cannot select is one the arbiter must never build. So a link out of the
805
+ * repository, or into a path git does not track, keeps its declared spelling
806
+ * rather than becoming an absolute path nothing can ground.
807
+ *
808
+ * An unreadable candidate (a dangling link, a race) is NOT dropped: it passed the
809
+ * existence filter against the listing, and silently losing a real prefix is the
810
+ * Result-5 hazard in the other direction.
811
+ * @param {{workdir: string, sourcePaths: any, seedFiles: string[]}} o
812
+ * @returns {string[]}
813
+ */
814
+ export function resolveSourcePrefixes({ workdir, sourcePaths, seedFiles }) {
815
+ const files = Array.isArray(seedFiles) ? seedFiles : [];
816
+ const listed = (/** @type {string} */ p) => p !== '' && files.some((f) => f === p || f.startsWith(`${p}/`));
817
+ const candidates = (Array.isArray(sourcePaths) ? sourcePaths : []).map(cleanEntry).filter(listed);
818
+
819
+ // the root's OWN realpath: a workdir reached through a link would otherwise
820
+ // make every candidate look like it points outside the repository
821
+ let root;
822
+ try { root = realpathSync(String(workdir ?? '')); } catch { root = String(workdir ?? ''); }
823
+
824
+ /** @type {Map<string, string>} realpath → the spelling that owns it */
825
+ const byReal = new Map();
826
+ for (const declared of candidates) {
827
+ let real = null;
828
+ try { real = realpathSync(join(root, declared)); } catch { real = null; }
829
+ let spelling = declared;
830
+ if (real !== null) {
831
+ const rel = relative(root, real);
832
+ // inside the repository, and something git tracks under it
833
+ if (rel !== '' && !rel.startsWith('..') && !isAbsolute(rel) && listed(rel)) spelling = rel;
834
+ }
835
+ const key = real ?? join(root, declared);
836
+ if (!byReal.has(key)) byReal.set(key, spelling);
837
+ }
838
+ return [...byReal.values()];
839
+ }
840
+
841
+ // ── 5. COST ──────────────────────────────────────────────────────────────────
842
+
843
+ /**
844
+ * F6's shape: an unpriced call or round makes the TOTAL unknown, and unknown is
845
+ * reported as null — never as the sum of the priced half wearing a complete coat.
846
+ * `knownUsd` carries the priced half explicitly so nothing is hidden either way.
847
+ *
848
+ * IT IS ALSO THE RUN'S AUDIT OF WHAT WAS SAID, not only of what was spent. Every
849
+ * `add` records the call's RAW output beside its price, through the one
850
+ * scrubbed-persist helper — one list of calls, read two ways. That pairing is
851
+ * structural rather than remembered: run mslhn707 metered a $0.053 scout call
852
+ * and kept not one byte of what it returned, so the malformation that killed the
853
+ * run could not be read afterwards. A cost entry with no raw beside it is that
854
+ * run again.
855
+ */
856
+ export function makeCostBook() {
857
+ /** @type {{label: string, costUsd: number|null, unpricedRounds: number}[]} */
858
+ const entries = [];
859
+ /** @type {ReturnType<typeof scrubRaw>[]} */
860
+ const raws = [];
861
+ return {
862
+ /** @param {string} label @param {any} r a bare-agent run result @param {number} [attempt] */
863
+ add(label, r, attempt = 1) {
864
+ entries.push({ label, ...priceOf(r) });
865
+ raws.push(scrubRaw({ label, attempt, text: r?.text, reason: r?.error ? String(r.error) : null }));
866
+ return entries.at(-1);
867
+ },
868
+ /** @param {{label: string, costUsd: number|null, unpricedRounds: number}[]} calls
869
+ * @param {ReturnType<typeof scrubRaw>[]} [callRaws] */
870
+ absorb(calls, callRaws) { for (const c of calls ?? []) entries.push({ ...c }); for (const w of callRaws ?? []) raws.push({ ...w }); },
871
+ /** the persisted raws, in call order — the same order and the same labels as
872
+ * `report().calls`, because both are readings of one list */
873
+ raws() { return raws.map((w) => ({ ...w })); },
874
+ 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
+ };
887
+ },
888
+ };
889
+ }
890
+
891
+ /**
892
+ * The default model boundary: one bare-agent Loop per call, with the declaration
893
+ * tool wired to END the call the moment it is used. The tool is an OUTPUT
894
+ * CHANNEL, not a step in a conversation, so the round that would acknowledge its
895
+ * result has nothing left to say.
896
+ *
897
+ * MEASURED against the real Loop with a counting provider, both arms: stop-wired
898
+ * = 1 provider round, $0.001; unwired = 2 rounds, $0.002. So the wiring halves
899
+ * the round count of every authoring and revise call — that is the number, not
900
+ * an estimate, and it is why tool mode does not cost double what text mode did.
901
+ *
902
+ * `cacheMessages` (F18) and `maxTokens` (F30) are not optional on this stack.
903
+ * @param {any} provider @param {{system?: string, maxTokens?: number}} [o]
904
+ * @returns {(messages: any[], tools: any[], opts?: any) => Promise<any>}
905
+ */
906
+ export function makeLoopGenerate(provider, { system = AUTHOR_SYSTEM, maxTokens = AUTHOR_MAX_TOKENS } = {}) {
907
+ return async (/** @type {any[]} */ messages, /** @type {any[]} */ tools, /** @type {any} */ opts = {}) => {
908
+ const loop = new Loop({ provider, system });
909
+ const wired = tools.map((t) => ({
910
+ ...t,
911
+ execute: async (/** @type {any} */ a) => { const r = await t.execute(a); loop.stop(); return r; },
912
+ }));
913
+ return loop.run(messages, wired, { cacheMessages: true, maxTokens, ...opts });
914
+ };
915
+ }
916
+
917
+ // ── 6. THE FLOW ──────────────────────────────────────────────────────────────
918
+
919
+ /** key-order-insensitive structural identity, for the early stop @param {any} x */
920
+ function canonical(x) {
921
+ if (x === null || typeof x !== 'object') return JSON.stringify(x) ?? 'null';
922
+ if (Array.isArray(x)) return `[${x.map(canonical).join(',')}]`;
923
+ return `{${Object.keys(x).sort().map((k) => `${JSON.stringify(k)}:${canonical(x[k])}`).join(',')}}`;
924
+ }
925
+
926
+ /**
927
+ * ONE structured ask, with the malformed-emission retry bounded on its OWN axis.
928
+ * A reply that carries no declaration is an ARTIFACT-RED — name the axis, retry
929
+ * under a cap, write nothing — and it never consumes a revision, because a
930
+ * transport fault is not a defect in a close.
931
+ * @param {{messages: any[], generate: Function, mode: 'tool'|'text', retries: number,
932
+ * label: string, book: ReturnType<typeof makeCostBook>, catalogue: Record<string, any>}} o
933
+ */
934
+ async function askDeclaration({ messages, generate, mode, retries, label, book, catalogue }) {
935
+ /** @type {any[]} */
936
+ const convo = [...messages];
937
+ let attempts = 0;
938
+ /** @type {Red|null} */
939
+ let red = null;
940
+
941
+ for (let attempt = 0; attempt <= retries; attempt += 1) {
942
+ /** @type {{calls: any[]}} */
943
+ const box = { calls: [] };
944
+ const tools = mode === 'tool' ? [declarationTool(box, { catalogue })] : [];
945
+ const r = await generate(convo, tools, {});
946
+ attempts += 1;
947
+ book.add(attempt === 0 ? label : `${label}#${attempt + 1}`, r, attempts);
948
+ const raw = redactSecrets(String(r?.text ?? ''));
949
+
950
+ if (r?.error) {
951
+ return { declaration: null, attempts, convo, raw, providerError: String(r.error), red: null };
952
+ }
953
+
954
+ if (mode === 'tool') {
955
+ if (box.calls.length === 1) return { declaration: box.calls[0], attempts, convo, raw, providerError: null, red: null };
956
+ red = {
957
+ code: 'artifact-red',
958
+ path: DECLARATION_TOOL_NAME,
959
+ detail: box.calls.length === 0
960
+ ? `the reply delivered no ${DECLARATION_TOOL_NAME} call — structure is enforced, prose is never parsed`
961
+ : `the reply delivered ${box.calls.length} ${DECLARATION_TOOL_NAME} calls; exactly one declaration is expected`,
962
+ axis: box.calls.length === 0 ? 'no-declaration-tool-call' : 'multiple-declaration-tool-calls',
963
+ };
964
+ } else {
965
+ // THE FALLBACK PATH — providers without tool mode only. The ONE shipped
966
+ // parser, never a second one.
967
+ const { code, red: extractRed } = extractArtifact(raw);
968
+ if (code) {
969
+ try { return { declaration: JSON.parse(code), attempts, convo, raw, providerError: null, red: null }; }
970
+ 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
+ } else {
972
+ red = { code: 'artifact-red', path: 'declaration', detail: `no artifact in the reply: ${extractRed}`, axis: 'no-artifact' };
973
+ }
974
+ }
975
+
976
+ if (attempt < retries) {
977
+ const block = renderRejectBlock({ kind: 'artifact', reds: [/** @type {Red} */ (red).detail] });
978
+ convo.push({ role: 'assistant', content: raw.trim() || '(the reply carried no text)' });
979
+ convo.push({ role: 'user', content: `${block}\n\n${mode === 'tool' ? STRUCTURE_INSTRUCTION_TOOL : STRUCTURE_INSTRUCTION_TEXT}` });
980
+ }
981
+ }
982
+ return { declaration: null, attempts, convo, raw: '', providerError: null, red };
983
+ }
984
+
985
+ /**
986
+ * THE AUTHORING FLOW.
987
+ *
988
+ * Everything cheap happens BEFORE any token is spent: an ABSENT survey, an
989
+ * unreadable listing, an unsupported genre and a genre variable that cannot be
990
+ * built are all $0 refusals, because a paid call that was always going to be
991
+ * thrown away is a paid call nobody needed.
992
+ *
993
+ * The return is deliberately complete rather than tidy:
994
+ * - `ok` is true iff a declaration passed validation and was measured;
995
+ * - `declaration` is the LAST ACCEPTED one, genre-env injected — the exact form
996
+ * that will run and that the signature will cover;
997
+ * - `reds` is what the LAST validated iteration said. `ok:true` with a non-empty
998
+ * `reds` means the final revision was rejected and the previous close stands:
999
+ * the rejection is reported, never hidden, and never silently shipped either;
1000
+ * - `iterations` carries every intermediate declaration, validation, seed read
1001
+ * and attempt count, because M4 emits them and a human signs against them;
1002
+ * - `raws` carries what every model call actually SAID — the scout's attempts
1003
+ * and the declaration's, scrubbed and bounded through the one persist
1004
+ * helper, in the cost book's own order and under its own labels. It is
1005
+ * present on EVERY path including the $0 preflight refusals, because the
1006
+ * path that spends nothing more is exactly the one whose evidence used to
1007
+ * vanish with the process.
1008
+ *
1009
+ * No spine emission here (M4 wires events).
1010
+ *
1011
+ * @param {{workdir: string, seedRef: string, lang: string, verdictType: string,
1012
+ * answers: Record<string|number, string>, questions?: Record<string|number, string>,
1013
+ * scout: {state: string, facts: any, reason?: string|null, calls?: any[], raws?: any[]},
1014
+ * listing?: {stop: string|null, files: string[]|null, block: string|null, meta: any}|null,
1015
+ * generate: Function, seedReadFn?: Function, closeCtx?: any,
1016
+ * maxRevisions?: number, structureRetries?: number,
1017
+ * structuredMode?: 'tool'|'text', catalogue?: Record<string, any>}} o
1018
+ */
1019
+ export async function authorClose({
1020
+ workdir, seedRef, lang, verdictType,
1021
+ answers, questions = GREEN_QUESTIONS,
1022
+ scout, listing = null,
1023
+ generate, seedReadFn = runSeedReadStages, closeCtx = {},
1024
+ maxRevisions = MAX_REVISIONS,
1025
+ structureRetries = MAX_STRUCTURE_RETRIES,
1026
+ structuredMode = 'tool',
1027
+ catalogue = KIND_CATALOGUE,
1028
+ }) {
1029
+ // TIGHTEN-ONLY: a caller may lower the ceiling, never raise it — unlimited
1030
+ // revising launders thrash as adaptation (addendum 5). The floor is zero: one
1031
+ // authoring call with no revise round is a legal ask, and a garbage value is
1032
+ // the floor rather than a negative bound the loop never enters.
1033
+ const revisionCap = Math.max(0, Math.min(Math.trunc(Number(maxRevisions)) || 0, MAX_REVISIONS));
1034
+ // TIGHTEN-ONLY on the OTHER axis too, for the identical reason. `structureRetries`
1035
+ // buys provider calls exactly as `maxRevisions` does, and it rode straight through
1036
+ // to `askDeclaration` unclamped — a caller handing 50 got 50 paid malformed-emission
1037
+ // retries above a ceiling this module declares and the suite pins. No new number:
1038
+ // the bound is `MAX_STRUCTURE_RETRIES`, the constant already documented as the
1039
+ // ceiling. Floor 0, exactly as above: `askDeclaration` loops `attempt <= retries`,
1040
+ // so 0 is still ONE attempt with no retry — a legal tighter ask, not an absent one.
1041
+ const retryCap = Math.max(0, Math.min(Math.trunc(Number(structureRetries)) || 0, MAX_STRUCTURE_RETRIES));
1042
+ const book = makeCostBook();
1043
+ // The scout's calls AND its raws, together. The raws matter most on the path
1044
+ // that spends nothing more: run mslhn707 refused at the $0 preflight below,
1045
+ // and the survey text that would have said WHY died with the process.
1046
+ book.absorb(scout?.calls ?? [], scout?.raws ?? []);
1047
+ /** @type {any} */
1048
+ const base = {
1049
+ ok: false, declaration: null, seedRead: null, iterations: [], reds: [], cost: book.report(),
1050
+ raws: book.raws(),
1051
+ facts: null, listing: null, genreEnv: null, finalFrom: null, stop: null, revisions: 0,
1052
+ };
1053
+ const refuse = (/** @type {Red[]} */ reds, /** @type {string} */ stop) => ({ ...base, reds, stop, cost: book.report(), raws: book.raws() });
1054
+
1055
+ // ── $0 preflight ──────────────────────────────────────────────────────────
1056
+ // F59, in the shape that bites: `{}` is the scout not completing, never "no
1057
+ // special facts are needed". This one lands in a SIGNED artefact.
1058
+ if (scout?.state !== 'PRESENT' || !scout.facts || typeof scout.facts !== 'object'
1059
+ || Object.keys(scout.facts).length === 0) {
1060
+ return refuse([{
1061
+ code: 'scout-absent',
1062
+ path: 'scout',
1063
+ // `scout.reason` is half survey bookkeeping and half the scout's own error
1064
+ // string (its `meta.error`), so provider prose reaches a KEPT red here —
1065
+ // scrubbed at the emission boundary like every other channel that quotes
1066
+ // something this module does not own
1067
+ detail: `the survey is ${scout?.state ?? 'missing'}${scout?.reason ? ` (${redactSecrets(scout.reason)})` : ''} — an absent facts `
1068
+ + 'object is never "no special facts are needed", and there is nothing to author from',
1069
+ }], 'precheck');
1070
+ }
1071
+ const facts = scout.facts;
1072
+ base.facts = facts;
1073
+
1074
+ // THE PICKED CLASS, checked at $0 before the language: the battery attaches to
1075
+ // the CLASS (PRD v1.57 §2), so a class v1 does not build has no battery to
1076
+ // attach — and a close authored without the guards it cannot supply is exactly
1077
+ // what D5 exists to make impossible. `verb` rides as a structured field so the
1078
+ // caller can carry this to the counted admission path rather than re-deriving
1079
+ // the demand from prose.
1080
+ if (!LIVE_CLASSES.includes(String(verdictType))) {
1081
+ const known = VERDICT_CLASSES.includes(String(verdictType));
1082
+ return refuse([{
1083
+ code: 'class-unsupported',
1084
+ path: 'verdictType',
1085
+ verb: String(verdictType),
1086
+ lib: 'bareloop',
1087
+ detail: known
1088
+ ? `"${verdictType}" is a named but LOCKED verdict class — v1 admits ${LIVE_CLASSES.join(', ')} only, and a `
1089
+ + 'class with no guard battery must refuse rather than author a close without the guards it cannot supply'
1090
+ : `"${verdictType}" is not a verdict class — the menu is ${VERDICT_CLASSES.join(' | ')}, and nothing derives `
1091
+ + 'a class from the answers',
1092
+ }], 'precheck');
1093
+ }
1094
+
1095
+ if (!GENRE_LANGUAGES.includes(lang)) {
1096
+ return refuse([{
1097
+ code: 'genre-unsupported',
1098
+ path: 'lang',
1099
+ verb: 'genre-other',
1100
+ lib: 'bareloop',
1101
+ detail: `no TYPES genre data for language "${lang}" — one of ${GENRE_LANGUAGES.join(', ')}. A genre with no `
1102
+ + 'guard battery must refuse, never author a close without the guards it cannot supply',
1103
+ }], 'precheck');
1104
+ }
1105
+
1106
+ const seeds = listing ?? await buildSeedListing({
1107
+ workdir, seedRef, sourcePaths: facts.sourcePaths, testPaths: facts.testPaths,
1108
+ });
1109
+ const seedFiles = Array.isArray(seeds.files) ? seeds.files : null;
1110
+ if (seeds.stop !== null || seedFiles === null || seedFiles.length === 0) {
1111
+ return refuse([{
1112
+ code: seeds.stop ? 'listing-unreadable' : 'listing-absent',
1113
+ path: 'listing',
1114
+ // `stop` is git's own stderr (kinds.js builds it from the subprocess);
1115
+ // scrubbed HERE, at the emission boundary, because this red LEAVES the flow
1116
+ // and is kept — M1 never scrubs, the caller does
1117
+ detail: seeds.stop != null
1118
+ ? redactSecrets(seeds.stop)
1119
+ : 'the seed listing is empty — without it nothing can tell a real path from an invented one',
1120
+ }], 'precheck');
1121
+ }
1122
+ base.facts = facts;
1123
+ base.listing = seeds;
1124
+
1125
+ const guards = classGuards({ verdictType, lang });
1126
+ const ownedEnvNames = genreOwnedEnvNames(lang);
1127
+ // the source prefixes the genre's environment is built from, filtered against
1128
+ // the REAL tree — a prefix that names nothing would build a variable pointing
1129
+ // at nothing, and a symlink plus the directory behind it would name ONE tree
1130
+ // twice (see resolveSourcePrefixes: that is a FATAL mypy exit, not a warning)
1131
+ const sourcePrefixes = resolveSourcePrefixes({ workdir, sourcePaths: facts.sourcePaths, seedFiles });
1132
+ const envProbe = applyGenreEnv({ stages: [] }, lang, { sourcePrefixes, catalogue });
1133
+ base.genreEnv = { applied: envProbe.applied, owned: ownedEnvNames, missing: envProbe.missing, dropped: [] };
1134
+ if (envProbe.reds.length) return refuse(envProbe.reds, 'precheck');
1135
+
1136
+ // ── the grounded loop ─────────────────────────────────────────────────────
1137
+ const prompt = authorPrompt({
1138
+ answers, questions, facts, listingBlock: /** @type {string} */ (seeds.block),
1139
+ lang, verdictType, guards, ownedEnvNames, mode: structuredMode, catalogue,
1140
+ });
1141
+ /** @type {any[]} */
1142
+ let messages = [{ role: 'user', content: prompt }];
1143
+ /** @type {any[]} */
1144
+ const iterations = [];
1145
+ /** @type {any} */
1146
+ let previous = null;
1147
+ /** @type {any} */
1148
+ let accepted = null;
1149
+ /** @type {any[]|null} */
1150
+ let acceptedSeedRead = null;
1151
+ /** @type {string|null} */
1152
+ let finalFrom = null;
1153
+ /** @type {{ok: boolean, reds: Red[]}|null} */
1154
+ let lastValidation = null;
1155
+ /** @type {Red[]} */
1156
+ let fatal = [];
1157
+ let stop = 'max-revisions';
1158
+ /** @type {{stage: string, name: string}[]} */
1159
+ let droppedEnv = [];
1160
+
1161
+ const seedTrees = makeSeedTrees();
1162
+ try {
1163
+ for (let i = 0; i <= revisionCap; i += 1) {
1164
+ const label = i === 0 ? 'author' : `revise-${i}`;
1165
+ const ask = await askDeclaration({ messages, generate, mode: structuredMode, retries: retryCap, label, book, catalogue });
1166
+ messages = ask.convo;
1167
+ /** @type {any} */
1168
+ const iter = {
1169
+ call: label, attempts: ask.attempts, declaration: ask.declaration ?? null,
1170
+ artifactRed: ask.red ?? null, providerError: ask.providerError ?? null,
1171
+ validation: null, seedRead: null, reviseTurn: null,
1172
+ };
1173
+ iterations.push(iter);
1174
+
1175
+ if (ask.providerError) {
1176
+ // a transport failure is a casualty, never evidence about the model — and
1177
+ // the transport's own prose (a proxy URL, an auth echo) is scrubbed on the
1178
+ // way into a red the caller keeps
1179
+ fatal = [{ code: 'provider-red', path: label, detail: redactSecrets(ask.providerError) }];
1180
+ stop = 'provider-red';
1181
+ break;
1182
+ }
1183
+ if (!ask.declaration) {
1184
+ fatal = [/** @type {Red} */ (ask.red)];
1185
+ stop = 'artifact-red';
1186
+ break;
1187
+ }
1188
+
1189
+ // early stop: an unchanged declaration has the previous iteration's
1190
+ // measurements by definition — re-executing it buys nothing and costs a
1191
+ // full close
1192
+ if (previous && canonical(ask.declaration) === canonical(previous)) {
1193
+ iter.validation = '(identical declaration — see the previous iteration)';
1194
+ iter.seedRead = '(identical declaration — see the previous iteration)';
1195
+ stop = 'unchanged';
1196
+ break;
1197
+ }
1198
+ previous = ask.declaration;
1199
+
1200
+ const v = validateDeclaration(ask.declaration, { catalogue, listing: seedFiles, guards, envOwned: ownedEnvNames, verdictType });
1201
+ // SCRUBBED HERE, once, where the reds enter this module's records — the same
1202
+ // boundary rule `renderRejectBlock` already states, applied to the OTHER
1203
+ // channel they travel down. A validation red quotes what the model declared
1204
+ // verbatim (`cmd-denied` carries the whole `cmd`, the genre-env branch carries
1205
+ // the declared value, the listing rule carries the path), and these copies are
1206
+ // PERSISTED: they ride `iterations[].validation` and this function's returned
1207
+ // `reds` into `authored.json` and into the spine (`emit('job-red', …)`), which
1208
+ // is append-only — a record that captures a key captures it forever. The
1209
+ // model-facing render was masked and the persisted twin was not.
1210
+ const vReds = v.reds.map(scrubRed);
1211
+ iter.validation = { ok: v.ok, reds: vReds, scoped: v.scoped };
1212
+ lastValidation = { ok: v.ok, reds: vReds };
1213
+
1214
+ /** @type {string} */
1215
+ let measured;
1216
+ if (!v.ok) {
1217
+ // a validation red IS a measurement of the declaration, so it rides the
1218
+ // same channel and counts as a revision (addendum 5)
1219
+ measured = renderRejectBlock({ kind: 'validation', reds: vReds });
1220
+ } else {
1221
+ // what gets MEASURED is what will RUN: the resolved declaration with the
1222
+ // genre's environment already injected
1223
+ const injected = applyGenreEnv(v.declaration, lang, { sourcePrefixes, catalogue });
1224
+ droppedEnv = injected.dropped;
1225
+ // `gapKeep` defaults to EMPTY rather than being left undefined: M1
1226
+ // prefixes every gap line with it verbatim, so an unset one prints the
1227
+ // word "undefined" on every failure line the model is asked to read
1228
+ const rows = await seedReadFn(injected.declaration, { gapKeep: '', ...closeCtx, workdir, seedRef, seedTrees });
1229
+ iter.seedRead = rows;
1230
+ accepted = injected.declaration;
1231
+ acceptedSeedRead = rows;
1232
+ finalFrom = label;
1233
+ measured = renderSeedReadBlock(rows);
1234
+ }
1235
+
1236
+ if (i === revisionCap) { stop = 'max-revisions'; break; }
1237
+
1238
+ const turn = buildReviseTurn(measured);
1239
+ // A TRIPWIRE, not behaviour: it can only fire once something else is
1240
+ // already broken, so it is the one guard in this module the mutation
1241
+ // battery cannot kill — removing it changes no output today. It is kept
1242
+ // deliberately, and what the suite pins instead is the invariant it
1243
+ // protects: the bytes leaving this loop are exactly
1244
+ // `buildReviseTurn(renderSeedReadBlock(rows))`, so a future edit that
1245
+ // appends one helpful sentence here reds the suite either way.
1246
+ assertReviseTurn(turn, measured);
1247
+ iter.reviseTurn = { bytes: Buffer.byteLength(turn) };
1248
+ messages = [...messages, { role: 'assistant', content: ask.raw.trim() || '(the reply carried no text)' }, { role: 'user', content: turn }];
1249
+ }
1250
+ } finally {
1251
+ await seedTrees.cleanup();
1252
+ }
1253
+
1254
+ const reds = fatal.length ? fatal : (lastValidation && !lastValidation.ok ? lastValidation.reds : []);
1255
+ return {
1256
+ // `ok` means A SIGNABLE CLOSE EXISTS, and nothing more. A dead socket on a
1257
+ // LATER revise call does not invalidate a close that was already validated
1258
+ // and already measured — a casualty is never evidence (F45), and throwing a
1259
+ // paid, measured close away over a transport fault is the wrong direction.
1260
+ // Why the loop ended is never hidden: `stop` names it and `reds` carries it.
1261
+ ok: accepted !== null,
1262
+ declaration: accepted,
1263
+ seedRead: acceptedSeedRead,
1264
+ iterations,
1265
+ reds,
1266
+ cost: book.report(),
1267
+ // WHAT THE MODEL ACTUALLY SAID, every call of it, on the run's own audit
1268
+ // trail — the scout's attempts and the declaration's, in call order, under
1269
+ // the same labels the cost book meters. `iterations` records what each turn
1270
+ // MEANT (declaration, validation, seed read); this records what it SAID, and
1271
+ // a malformation is only ever visible in the second.
1272
+ raws: book.raws(),
1273
+ facts,
1274
+ listing: seeds,
1275
+ genreEnv: { ...base.genreEnv, dropped: droppedEnv },
1276
+ finalFrom,
1277
+ stop,
1278
+ revisions: Math.max(0, iterations.length - 1),
1279
+ };
1280
+ }