bareloop 0.25.1 → 0.26.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/authorflow.js CHANGED
@@ -153,108 +153,174 @@ export const STRUCTURE_INSTRUCTION_TEXT = 'Your reply could not be read as one J
153
153
  + 'Reply with ONLY the JSON object, in a single fenced block, and nothing else.';
154
154
 
155
155
  /**
156
- * THE INTERVIEW, KEYED BY VERDICT CLASS (hamr's ruling, PRD v1.57 §2).
156
+ * THE UNIFIED INTAKE FORM (PRD item 33 M3 piece 3, `docs/product/ITEM33-BUILD.md`
157
+ * "M3", and the 2026-09-10 signed ruling "The intake form (RULED)" in
158
+ * `docs/product/PRD.md` item 33). Motivated by run `mtv8jihy` (F159): the OLD
159
+ * five-question green set produced a rubric compiled onto doc-comment rules —
160
+ * the wrong bar for what the person actually asked — because Q2 asked in prose
161
+ * for a decision the machine can prove instead, and Q3/Q5 asked the same "what
162
+ * must not go wrong" question twice from two directions.
157
163
  *
158
- * One question set per GENRE has no terminating shape — genres are a fat long
159
- * tail that cannot be enumerated, and TYPES was the first specimen rather than
160
- * the pattern. One set per verdict CLASS has exactly three. Genre understanding
161
- * does not disappear: it moves into the COMPOSITION over the kind catalogue
162
- * (`CLASS_STATEMENTS` below, rendered into the authoring prompt), which is the
163
- * layer already built to hold it — the authoring call composes owned kinds and
164
- * cannot emit anything else (D2/D3), so a genre it has never seen is a
165
- * composition problem, not a catalogue problem.
164
+ * THE SAME SIX FIELDS FOR GREEN AND SOFT-GREEN, in the order a person sees them:
166
165
  *
167
- * The GREEN set descends from questions 1–6 of the frozen TYPES set (prereg §5).
168
- * TWO slots have been deleted since, both because the interview was ASKING FOR
169
- * SOMETHING IT ALREADY HAD or already knew:
166
+ * Source replaces the read half of the old Q2 — MECHANICAL, proven
167
+ * against the machine ($0), never typed as free prose.
168
+ * Destination replaces the change half of the old Q2 (and, for a repo
169
+ * source, IS the signed `writeScope` fence) — MECHANICAL,
170
+ * same reason.
171
+ * Goal the old Q1, unchanged: "What do you want done?"
172
+ * What success looks like the old Q4, unchanged: a machine-countable check.
173
+ * Guardrails the old Q3 ("must not change") and Q5 ("worse than
174
+ * before") JOINED into one field — they were always one
175
+ * decision answered twice, and Q5 already only mattered for
176
+ * a repo source (the M3 confirm turn, not built in this
177
+ * piece, is where the repo-only half of it gets asked again
178
+ * if needed).
179
+ * Judge examples soft-green only — the old Q7 verbatim ("one you'd pass and
180
+ * one you'd fail, and say why"). The old Q6 ("what separates
181
+ * a pass from a fail") is retired as its own question: the
182
+ * "why" lines of a real pass/fail pair are what the rubric
183
+ * card compiles from (`cardauthor.js`), and asking the
184
+ * differentiator twice — once in the abstract, once against
185
+ * a real example — is the same double-ask Q3/Q5 already were.
170
186
  *
171
- * - D13's genre CONFIRM ("this looks like a type-fixing job, correct?") —
172
- * superseded in full: the interview never asks about a genre again, and the
173
- * refusal it carried MOVED to the composer, on the same counted
174
- * `request-red` path (see `authorCloseForJob`);
175
- * - "Is there a code repo I can look at? Where?" — DROPPED on hamr's ruling
176
- * after he drove the terminal interview himself (2026-08-15, verbatim: *"drop
177
- * Q6"*). The repository is a MANDATORY, STRUCTURED input already —
178
- * `runInterview`'s own `repoPath`, which the runner takes from `--patient` and
179
- * refuses to start without. Asking a person to re-type in prose a path the
180
- * machine is holding in a variable is the SWE tax this product refuses, and
181
- * it invites a second, drifting answer for the same fact (hamr's own answer
182
- * on the live run was *"Yes — the patient itself."*). Nothing downstream lost
183
- * anything: the scout reads `repoPath`, never the answer.
187
+ * SOURCE and DESTINATION ARE MECHANICAL FIELDS, not entries in the numbered
188
+ * question record below: `runInterview`'s `answers` dict is what a close's
189
+ * composer prompt quotes verbatim (Q<n>/A<n>), and quoting a machine-proven fact
190
+ * back at the model as if a person had typed it would be the model reading a
191
+ * fabricated conversation. Their WORDING still lives here (`SOURCE_FIELD`,
192
+ * `DESTINATION_FIELD_REPO`, `DESTINATION_FIELD_PLAIN`, immediately below) so
193
+ * `scripts/run-interview.mjs` prints library text and nothing of its own, exactly
194
+ * as ruling 1 requires for the free-text questions.
184
195
  *
185
- * Question 2 was WIDENED in the same ruling: it now asks for the read-or-draw-from
186
- * files as well as the ones that change. The two halves belong in one answer
187
- * because they are one decision for the person answering — what the work touches,
188
- * and what it touches only to look at.
196
+ * The free-text trio (soon quartet, soft-green only) IS numbered contiguously
197
+ * from 1 — the number a person sees is the key their answer is filed under, and
198
+ * that is what `questionsFor`/`requiredAnswersFor`/`QUESTION_SETS` still expose,
199
+ * unchanged in shape, to every existing caller.
200
+ */
201
+ export const SOURCE_FIELD = Object.freeze({
202
+ id: 'source',
203
+ kind: 'mechanical',
204
+ label: 'Source',
205
+ prompt: 'A local folder, subfolder or file, or one URL the machine can reach (no login, no setup).',
206
+ });
207
+
208
+ /** Destination's wording when Source resolved to a git repo: the answer IS the
209
+ * signed `writeScope` fence (ITEM33-BUILD.md M3 ruling 2). */
210
+ export const DESTINATION_FIELD_REPO = Object.freeze({
211
+ id: 'destination',
212
+ kind: 'mechanical',
213
+ label: 'Destination',
214
+ prompt: 'Which files the worker is allowed to WRITE — everything else is read-only.\n'
215
+ + 'Patterns are relative to the repo root, comma-separated (e.g. `src/**`). The run works on a copy of the\n'
216
+ + 'repo, so absolute paths are refused. The agent may narrow this and may never widen it.',
217
+ });
218
+
219
+ /** Destination's wording for every other kind of Source (a plain folder, a
220
+ * file, a URL): a directory the run may write into, never a filename
221
+ * (2026-09-12 Destination correction, PRD item 33). */
222
+ export const DESTINATION_FIELD_PLAIN = Object.freeze({
223
+ id: 'destination',
224
+ kind: 'mechanical',
225
+ label: 'Destination',
226
+ prompt: 'A LOCAL DIRECTORY the run may write into — never a filename. It may already exist. If it is the same\n'
227
+ + 'directory as Source, changes are permitted there and nowhere else. Type an absolute path.',
228
+ });
229
+
230
+ /** @param {boolean} isRepo @returns {typeof DESTINATION_FIELD_REPO} */
231
+ export function destinationFieldFor(isRepo) { return isRepo ? DESTINATION_FIELD_REPO : DESTINATION_FIELD_PLAIN; }
232
+
233
+ /**
234
+ * THE FREE-TEXT TRIO EVERY CLASS SHARES: Goal, What success looks like,
235
+ * Guardrails — numbered contiguously from 1. This is what `answers[1..3]` are
236
+ * filed under for every class; soft-green appends a fourth
237
+ * (`SOFTGREEN_QUESTIONS`) and hitl appends a different fourth
238
+ * (`HITL_QUESTIONS`), each its own next number rather than a fixed one, so a
239
+ * future trim of this trio renumbers instead of leaving a gap.
189
240
  *
190
- * The five that remain are genre-neutral, which is why every generalisation so far
191
- * has deleted a slot rather than reworded one. They are NUMBERED CONTIGUOUSLY from
192
- * 1: the number a person sees is the key their answer is filed under, and a gap
193
- * would be a hole in a form nobody can explain.
241
+ * THE TEXT IS THE SIGNED TABLE'S "Holds" COLUMN, VERBATIM (PRD item 33's
242
+ * 2026-09-10 "The intake form (RULED)" ruling, `docs/product/PRD.md`) — not the
243
+ * old worded questions ("What do you want done?", "How do you check today
244
+ * whether it's working?", "...worse than before?"). Motivated by run `mtv8jihy`
245
+ * (F159, `docs/logs/FINDINGS.md`): a rulebook this prompt commit did not touch
246
+ * still had to compile against a card built from a person's actual bar, and the
247
+ * old wording never matched what got signed — this reshape (commit 1ac4d05)
248
+ * trimmed the SET to three slots but left their PROSE unreconciled with the
249
+ * table it was meant to realize. `FIELD_LABELS` (below) carries the matching
250
+ * label for each key, so a caller wanting BOTH shows them together — see
251
+ * `authorPrompt`'s interview block and `scripts/run-interview.mjs`.
194
252
  */
195
253
  export const GREEN_QUESTIONS = Object.freeze({
196
- 1: 'What do you want done?',
197
- 2: 'Which files or folders should change? And which files should the work read or draw from (they stay untouched)?',
198
- 3: 'Is there anything that must not change?',
199
- 4: 'How do you check today whether it\'s working?',
200
- 5: 'What would make you say this came back worse than before?',
254
+ 1: 'what you want to achieve',
255
+ 2: 'checks a machine can count',
256
+ 3: 'what must not happen or change',
201
257
  });
202
258
 
203
259
  /**
204
- * THE HITL SET (N4 slice 1) — the green questions BYTE FOR BYTE, plus one.
205
- *
206
- * The five are unchanged deliberately rather than economically: the
207
- * mechanical-first composition law (2026-08-07) says every hitl close is
208
- * *deterministic stages first, judge minimal, human last*, so a hitl job needs
209
- * exactly the same mechanical facts a green one does — what changes, what must
210
- * not, how it is checked today, what "worse" looks like. Rewording them would
211
- * fork one interview into two that drift.
212
- *
213
- * The last one is the only thing the green set cannot supply, and it is not a
214
- * flourish: `human-confirms` requires an `ask` — the question the signer answers
215
- * at the end of the run — and nothing else in the interview names it. Without it
216
- * the composer would have to invent what a person is deciding, which is the one
217
- * thing a human stage must never have done for it. It is the SPREAD's next number
218
- * rather than a fixed one, so a green-side deletion renumbers it instead of
219
- * leaving a gap (it was 7 while the green set held six, and is 6 now it holds five).
260
+ * The signed table's own label for each of {@link GREEN_QUESTIONS}' keys — "the
261
+ * Field column", where {@link GREEN_QUESTIONS} is "the Holds column". Kept as a
262
+ * SEPARATE map rather than folding the label into the question string itself:
263
+ * every existing caller of `questionsFor`/`requiredAnswersFor`/`QUESTION_SETS`
264
+ * (`src/authorjob.js`, `src/cardauthor.js`, `scripts/run-interview.mjs`) treats
265
+ * a question as a plain string handed verbatim into a prompt or a `Q<n>. `
266
+ * line, and folding a label in would change that shape for every one of them.
267
+ * A caller that wants the label calls {@link labelsFor} exactly the way it
268
+ * calls `questionsFor` — additive, nothing existing breaks.
220
269
  */
221
- export const HITL_QUESTIONS = Object.freeze({
222
- ...GREEN_QUESTIONS,
223
- 6: 'When you look at the finished result yourself, what are you deciding?',
270
+ export const FIELD_LABELS = Object.freeze({
271
+ 1: 'Goal',
272
+ 2: 'What success looks like',
273
+ 3: 'Guardrails',
274
+ });
275
+
276
+ /** The key Guardrails' merged question and Judge Examples both prove out at:
277
+ * green holds exactly 3 free-text slots, so 4 is the next number for both the
278
+ * soft-green and hitl extensions below — computed, never hardcoded twice. */
279
+ const NEXT_FREE_TEXT_KEY = Object.keys(GREEN_QUESTIONS).length + 1;
280
+
281
+ /** The soft-green quartet's own key for Judge Examples — exported so
282
+ * `cardauthor.js` reads the SAME number this module files the answer under,
283
+ * rather than a second, hand-typed "6"/"7". */
284
+ export const SOFTGREEN_JUDGE_EXAMPLES_KEY = NEXT_FREE_TEXT_KEY;
285
+
286
+ /** The Judge Examples question — the signed table's own Holds text, verbatim
287
+ * ("one pass, one fail, and why"), not the old worded Q7 ("Give one example
288
+ * you'd pass and one you'd fail, and say why."). Exported so `cardauthor.js`'s
289
+ * prompt and this module's own `SOFTGREEN_QUESTIONS` render the identical
290
+ * string; its label ("Judge examples") lives in `SOFTGREEN_FIELD_LABELS`. */
291
+ export const JUDGE_EXAMPLES_QUESTION = 'one pass, one fail, and why';
292
+
293
+ /** {@link FIELD_LABELS} plus Judge Examples' own label, for the soft-green set. */
294
+ export const SOFTGREEN_FIELD_LABELS = Object.freeze({
295
+ ...FIELD_LABELS,
296
+ [SOFTGREEN_JUDGE_EXAMPLES_KEY]: 'Judge examples',
224
297
  });
225
298
 
226
299
  /**
227
- * THE SOFTGREEN SET (module 3, design record §4.6) — the green questions BYTE
228
- * FOR BYTE, plus TWO.
229
- *
230
- * Same discipline as the hitl set, and for the same reason: a softgreen close is
231
- * *mechanical stages first, the judged stage after* (§4.5), so a softgreen job
232
- * needs every mechanical fact a green one does. No renumbering of the green set,
233
- * no genre question, nothing reworded.
234
- *
235
- * The two additions are the only things the green set cannot supply, and neither
236
- * is a flourish — they are the judged floor's two REQUIRED inputs:
237
- * - Q6 becomes the RUBRIC CARD (§4.3): the person's own pass/fail lines,
238
- * compiled into enumerated items, signed, and enumerated in the spec hash.
239
- * The documented ceiling rides with it — the judge catches violations of
240
- * STATED card items only, and the fix for a miss is a new card line and a
241
- * re-sign, never a smarter judge.
242
- * - Q7 becomes the frozen CALIBRATION SET (§4.4): the whole pipe must grade it
243
- * correctly before the close is signable.
244
- * Both are asked in the person's own terms — an example they would pass and one
245
- * they would fail, not a rubric they are asked to write — because compiling a lay
246
- * answer into extractable items is the LLM's job (D5, proposed-and-signed) and
247
- * asking the person to do it is the SWE tax this product refuses.
248
- *
249
- * What the two answers COMPILE INTO is module 4. This module carries them
250
- * exactly as the hitl set carries its own last answer: they ride in `answers`,
251
- * they reach the composer verbatim through the prompt's interview block, and
252
- * nothing here derives anything from them.
300
+ * THE SOFTGREEN SET — the green trio plus Judge Examples. A judged close needs
301
+ * exactly this one more thing the green set cannot supply: a real pass/fail
302
+ * pair in the person's own words, which module 4 (`cardauthor.js`) compiles
303
+ * into the signed rubric card and the frozen calibration set. It rides in
304
+ * `answers` and reaches the composer verbatim through the prompt's interview
305
+ * block, exactly as every other free-text answer does — this module derives
306
+ * nothing from it.
253
307
  */
254
308
  export const SOFTGREEN_QUESTIONS = Object.freeze({
255
309
  ...GREEN_QUESTIONS,
256
- 6: 'When you judge the result yourself, what separates a pass from a fail? Name the few things you actually look for.',
257
- 7: 'Give one example you\'d pass and one you\'d fail, and say why.',
310
+ [SOFTGREEN_JUDGE_EXAMPLES_KEY]: JUDGE_EXAMPLES_QUESTION,
311
+ });
312
+
313
+ /**
314
+ * THE HITL SET (N4 slice 1, kept in code only — never offered in the form or
315
+ * anywhere customer-facing, PRD item 33 M3 ruling 8) — the green trio plus one.
316
+ * `human-confirms` requires an `ask`, the question the signer answers at the
317
+ * end of the run, and nothing else in the interview names it; without it the
318
+ * composer would have to invent what a person is deciding. It is the green
319
+ * set's next number, not a fixed one, so a green-side trim renumbers it too.
320
+ */
321
+ export const HITL_QUESTIONS = Object.freeze({
322
+ ...GREEN_QUESTIONS,
323
+ [NEXT_FREE_TEXT_KEY]: 'When you look at the finished result yourself, what are you deciding?',
258
324
  });
259
325
 
260
326
  /**
@@ -265,22 +331,30 @@ export const SOFTGREEN_QUESTIONS = Object.freeze({
265
331
  * distinction, at the interview layer) — and a locked class refuses at ADMISSION,
266
332
  * before its questions run, so the two readers throw rather than hand back
267
333
  * nothing. A fourth class arrives locked, and the machinery is here for it.
268
- * @type {Record<string, {locked: boolean, questions: Record<string|number, string>|null, required: readonly number[]|null}>}
334
+ * `labels` travels ALONGSIDE `questions`, one label per key that the signed
335
+ * table names one for (PRD item 33 M3 piece 3's wording fix) — hitl's own
336
+ * fourth question has no table row (it is never customer-facing, ruling 8) and
337
+ * so carries none; a reader finding no label for a key falls back to showing
338
+ * none, never a fabricated one.
339
+ * @type {Record<string, {locked: boolean, questions: Record<string|number, string>|null, labels: Record<string|number, string>, required: readonly number[]|null}>}
269
340
  */
270
341
  export const QUESTION_SETS = Object.freeze({
271
342
  green: Object.freeze({
272
343
  locked: false,
273
344
  questions: GREEN_QUESTIONS,
345
+ labels: FIELD_LABELS,
274
346
  required: Object.freeze(Object.keys(GREEN_QUESTIONS).map(Number).sort((a, b) => a - b)),
275
347
  }),
276
348
  'soft-green': Object.freeze({
277
349
  locked: false,
278
350
  questions: SOFTGREEN_QUESTIONS,
351
+ labels: SOFTGREEN_FIELD_LABELS,
279
352
  required: Object.freeze(Object.keys(SOFTGREEN_QUESTIONS).map(Number).sort((a, b) => a - b)),
280
353
  }),
281
354
  hitl: Object.freeze({
282
355
  locked: false,
283
356
  questions: HITL_QUESTIONS,
357
+ labels: FIELD_LABELS,
284
358
  required: Object.freeze(Object.keys(HITL_QUESTIONS).map(Number).sort((a, b) => a - b)),
285
359
  }),
286
360
  });
@@ -323,7 +397,7 @@ export const CLASS_STATEMENTS = Object.freeze({
323
397
  + 'no command can hold.',
324
398
  });
325
399
 
326
- /** @param {string} verdictType @returns {{locked: boolean, questions: any, required: any}} */
400
+ /** @param {string} verdictType @returns {{locked: boolean, questions: any, labels: any, required: any}} */
327
401
  function questionSet(verdictType) {
328
402
  const set = Object.hasOwn(QUESTION_SETS, String(verdictType)) ? QUESTION_SETS[String(verdictType)] : null;
329
403
  if (!set) throw new Error(`no interview set for verdict class "${verdictType}" — one of ${VERDICT_CLASSES.join(', ')}`);
@@ -342,6 +416,128 @@ export function questionsFor(verdictType) { return questionSet(verdictType).ques
342
416
  /** The question numbers whose answer must be present for that class's interview
343
417
  * to be finished. @param {string} verdictType @returns {number[]} */
344
418
  export function requiredAnswersFor(verdictType) { return [...questionSet(verdictType).required]; }
419
+ /** The signed table's label for each of that class's question keys (PRD item
420
+ * 33's "The intake form (RULED)" ruling) — a key with no table row (hitl's own
421
+ * fourth) is simply absent, never a fabricated label. Throws on the same locked
422
+ * or unknown class {@link questionsFor} does, for the same reason.
423
+ * @param {string} verdictType @returns {Record<string|number, string>} */
424
+ export function labelsFor(verdictType) { return questionSet(verdictType).labels; }
425
+
426
+ // ── CONFIRM TURN — person-facing wording (PRD item 33 M3 piece 4) ──────────
427
+ //
428
+ // These three are the $0, no-provider half of the confirm turn (D7): asked
429
+ // before the scout ever runs, so a missing person at end of input stops at
430
+ // $0 rather than after a paid call. FROZEN wording, exactly like SOURCE_FIELD
431
+ // and DESTINATION_FIELD_* above — a caller prints these verbatim, never
432
+ // rewords or reorders them.
433
+
434
+ /**
435
+ * Asked ONLY for a repo source (ruling 2 addendum's D7) — a plain folder or a
436
+ * URL has no "before" a repo's own history gives it to compare against. This
437
+ * is the OLD Q5 wording, verbatim ("What would make you say this came back
438
+ * worse than before?"), which M3 piece 3's reshape folded into Guardrails —
439
+ * it survives here as its OWN field because the confirm turn (ruling 5's
440
+ * 2026-09-13 addendum) includes it as a constraint only when the person
441
+ * actually gave one, and folding it silently into the Guardrails free-text
442
+ * slot would lose that "present or absent" distinction. An empty answer is
443
+ * "nothing beyond what Guardrails already said" and is not required — see
444
+ * the frozen-wording test below: this phrase appears NOWHERE in
445
+ * {@link questionsFor}'s own sets, so a caller cannot double-ask it by
446
+ * reading the wrong table.
447
+ */
448
+ export const WORSE_THAN_BEFORE_FIELD = Object.freeze({
449
+ id: 'worseThanBefore',
450
+ kind: 'mechanical',
451
+ label: 'Worse than before',
452
+ prompt: 'What would make you say this came back worse than before?',
453
+ });
454
+
455
+ /**
456
+ * Asked ONLY when `src/detectlang.js`'s `detectLanguage` reports its
457
+ * `ambiguous` outcome — two supported languages' manifests at the same
458
+ * (nearest) directory level (M3 ruling 3, point 3). The candidate list is
459
+ * NOT embedded here: it travels with the caller's own `ambiguous` result
460
+ * (`langResult.candidates`), so this module never hand-types a second copy
461
+ * of the supported-language set. An unsupported pick still goes through the
462
+ * existing `language-unsupported` refusal — this field only offers a choice
463
+ * among the candidates `detectLanguage` actually found.
464
+ */
465
+ export const LANGUAGE_PICK_FIELD = Object.freeze({
466
+ id: 'language',
467
+ kind: 'mechanical',
468
+ label: 'Language',
469
+ prompt: 'This repo has more than one supported language\'s manifest at the same level. Which one is this job about?',
470
+ });
471
+
472
+ /**
473
+ * THE CONFIRM TURN'S MENU — a structured CHOICE the person picks from after
474
+ * each round, never free text matched against a pattern (ruling 6's "no
475
+ * hand-authored check matcher" reasoning applies here too: the person's own
476
+ * reply is read as one of these four keys, never parsed for intent).
477
+ * confirm — sign the plan and the drafted goal sentence as shown.
478
+ * fix — free text describing what to change; feeds round r+1, up
479
+ * to the 2-round cap (ruling 5); after round 2 a "fix" is
480
+ * passed to the composer verbatim and shown at signing as an
481
+ * open question rather than spending a 3rd call (D3).
482
+ * type-goal — the person's own sentence REPLACES the drafted goal,
483
+ * verbatim (redacted); everything else in the plan stands.
484
+ * start-over — abandon this confirm turn; the person reruns the
485
+ * interview from the beginning (`confirm-restart`).
486
+ * @type {Record<'confirm'|'fix'|'type-goal'|'start-over', string>}
487
+ */
488
+ export const CONFIRM_MENU = Object.freeze({
489
+ confirm: 'Confirm — sign this plan and goal as shown',
490
+ fix: 'Fix — describe what to change',
491
+ 'type-goal': 'Type the goal sentence yourself',
492
+ 'start-over': 'Start over — rerun the interview from the beginning',
493
+ });
494
+
495
+ /**
496
+ * PLAIN-ENGLISH lines for the guards `classGuards` can compose, keyed by
497
+ * guard NAME — frozen beside {@link CONFIRM_MENU} because both are constants
498
+ * the confirm turn shows a person verbatim. One entry per guard that exists
499
+ * in `MECHANICAL_GUARDS` (`src/authoring.js`); {@link confirmProtections}
500
+ * throws if a guard `classGuards` hands back has no line here, so a battery
501
+ * that grows a guard fails loudly instead of showing a blank description.
502
+ * @type {Record<string, string>}
503
+ */
504
+ export const GUARD_DESCRIPTIONS = Object.freeze({
505
+ 'changed-from-seed': 'the run must actually change at least one file in scope — a close that reports done '
506
+ + 'without touching anything is refused',
507
+ 'no-suppressions': 'the run may not silence a checker instead of fixing what it flags (things like @ts-ignore, '
508
+ + 'eslint-disable, or # type: ignore) — none of those may appear in the diff',
509
+ });
510
+
511
+ /**
512
+ * THE REAL PROTECTIONS a person is shown, and what {@link runConfirmTurn}
513
+ * records as `accepted.protections` — fixes finding #1 from live run
514
+ * mu0voeo4 (2026-09-14): the confirm turn used to show whatever prose the
515
+ * MODEL invented for `protections`, and that run's model invented a
516
+ * "behavior-preservation guard" this build cannot check — no kind in the
517
+ * catalogue can verify runtime behaviour, and the author call's own note
518
+ * elsewhere says outright that behaviour-preservation is not composed here.
519
+ * This reads the SAME guards `classGuards` will actually compose into the
520
+ * close — one source, never a second hand-typed list — rendered through the
521
+ * frozen line in {@link GUARD_DESCRIPTIONS}, plus the write fence (a real,
522
+ * signed fence — {@link writeScopeBlock}'s own subject) when one is set.
523
+ * @param {{verdictType: string, lang: string, writeScope?: string[]|null}} o
524
+ * @returns {string[]}
525
+ */
526
+ export function confirmProtections({ verdictType, lang, writeScope = null }) {
527
+ const guards = classGuards({ verdictType, lang });
528
+ const lines = guards.map((g) => {
529
+ const desc = GUARD_DESCRIPTIONS[g.name];
530
+ if (!desc) {
531
+ throw new Error(`[authorflow] guard "${g.name}" has no line in GUARD_DESCRIPTIONS — every real guard shown to `
532
+ + 'a person needs one, frozen beside CONFIRM_MENU');
533
+ }
534
+ return `${g.name} — ${desc}`;
535
+ });
536
+ if (Array.isArray(writeScope) && writeScope.length > 0) {
537
+ lines.push(`write fence — the run may only change files matching: ${writeScope.join(', ')}`);
538
+ }
539
+ return lines;
540
+ }
345
541
 
346
542
  export const AUTHOR_SYSTEM = 'You compose the DEFINITION OF DONE for an automated job: a declaration over a fixed '
347
543
  + 'catalogue of stage kinds whose implementations already exist. You never write code, a script, a shell fragment, '
@@ -737,6 +933,70 @@ export function lawsBlock() {
737
933
  sounds like it should be called.`;
738
934
  }
739
935
 
936
+ /**
937
+ * WHAT MAY CHANGE, AND WHAT IS ONLY READ — replaces the old Q2 (PRD item 33 M3
938
+ * piece 3). Q2 used to ask the person, in prose, which files change and which
939
+ * are read-only; that decision is now Destination, PROVEN against the
940
+ * repository rather than typed (`docs/product/ITEM33-BUILD.md` "M3", ruling 2),
941
+ * and for a repo source Destination's answer IS the signed `writeScope` fence.
942
+ * This states that fact to the composer instead of letting it vanish along
943
+ * with Q2 — the same "nothing the composer received before may silently
944
+ * disappear" rule `applyGenreEnv` already keeps for a dropped env var.
945
+ *
946
+ * THROWS on an empty `writeScope`: Destination is a MANDATORY fence
947
+ * (`run-interview.mjs` refuses an empty answer at capture), so a caller
948
+ * reaching this with nothing to state is a bareloop wiring bug, not a job with
949
+ * no fence — a composer told it may change everything guards nothing.
950
+ * @param {string[]} writeScope
951
+ */
952
+ export function writeScopeBlock(writeScope) {
953
+ if (!Array.isArray(writeScope) || writeScope.length === 0) {
954
+ throw new Error('[authorflow] writeScopeBlock needs a non-empty writeScope — Destination is the fence and a '
955
+ + 'composer call with nothing to state is a bareloop wiring bug, not a job authored with no fence');
956
+ }
957
+ return `WHAT MAY CHANGE, AND WHAT IS ONLY READ\n\n`
958
+ + 'The person\'s own Destination answer is a write fence, proven against the repository rather than typed as '
959
+ + 'prose. You may only change files matching one of these patterns:\n\n'
960
+ + `${writeScope.map((p) => ` - ${p}`).join('\n')}\n\n`
961
+ + 'Everything else in the facts object and the listing below is READ-ONLY: you may look at it, and any command '
962
+ + 'you run may read it, but no stage you declare may write, move or delete it.';
963
+ }
964
+
965
+ /**
966
+ * THE CONFIRMED-PLAN BLOCK (PRD item 33 M3 piece 4) — present only when the
967
+ * person already ran a confirm turn (`runConfirmTurn`'s `accepted`). States
968
+ * the composition law as an ORDER over the ACCEPTED checks, exactly the
969
+ * pattern {@link writeScopeBlock} already is for the fence: "compose these
970
+ * checks and no other" rather than a code matcher over the goal prose
971
+ * (ruling 6). Protections are restated as unchanged, never as checks to add.
972
+ * Open questions the person's own fix rounds left unresolved (D3) are named
973
+ * so the composer states them in notes rather than silently deciding them.
974
+ * `notChecked` (fix #1, run mu0voeo4) is anything the person asked for that
975
+ * no listed check can verify — shown so the composer names the gap in its
976
+ * own notes rather than the plan quietly reading as if everything asked for
977
+ * is covered.
978
+ * @param {{checks: string[], protections: string[], openQuestions?: string[], notChecked?: string[]}} confirmed
979
+ */
980
+ export function confirmedBlock(confirmed) {
981
+ const checks = confirmed.checks ?? [];
982
+ const protections = confirmed.protections ?? [];
983
+ const openQuestions = confirmed.openQuestions ?? [];
984
+ const notChecked = confirmed.notChecked ?? [];
985
+ return 'THE CONFIRMED PLAN — the person already saw and confirmed this in the confirm turn\n\n'
986
+ + 'Compose stages for these checks and no other — a genre never adds a check the goal did not ask for, and '
987
+ + 'neither do you:\n\n'
988
+ + `${checks.map((c) => ` - ${c}`).join('\n') || ' (none)'}\n\n`
989
+ + 'These protections are already always-on and unchanged by this plan — never compose one of these as a check:\n\n'
990
+ + `${protections.map((p) => ` - ${p}`).join('\n') || ' (none)'}\n\n`
991
+ + (notChecked.length
992
+ ? 'THE PERSON ASKED FOR THESE, BUT NOTHING CHECKS THEM (state these in your notes — never silently claim they '
993
+ + `are covered):\n\n${notChecked.map((n) => ` - ${n}`).join('\n')}\n\n`
994
+ : '')
995
+ + (openQuestions.length
996
+ ? `OPEN QUESTIONS the confirm turn could not resolve (state these in your notes, never decide them silently):\n\n${openQuestions.map((q) => ` - ${q}`).join('\n')}`
997
+ : 'The confirm turn left no open questions.');
998
+ }
999
+
740
1000
  /**
741
1001
  * The whole authoring prompt, as one string.
742
1002
  *
@@ -749,19 +1009,40 @@ export function lawsBlock() {
749
1009
  * the one thing that tells the composer what DONE is allowed to mean here; a
750
1010
  * defaulted class would be the prompt answering a question the user was asked.
751
1011
  *
1012
+ * `writeScope` carries what the old Q2 used to (PRD item 33 M3 piece 3): Q2 is
1013
+ * gone from the numbered interview, so what may change and what is read-only
1014
+ * reaches the composer through {@link writeScopeBlock} instead, fed from
1015
+ * Destination's own proven fence — never silently dropped.
1016
+ *
752
1017
  * @param {{answers: Record<string|number, string>, questions?: Record<string|number, string>,
753
1018
  * facts: any, listingBlock: string, lang: string, verdictType: string,
754
1019
  * guards: {name: string, kind: string, params: Record<string, any>, fill: string[]}[],
755
- * ownedEnvNames?: string[], mode?: 'tool'|'text', catalogue?: Record<string, any>}} o
1020
+ * ownedEnvNames?: string[], mode?: 'tool'|'text', catalogue?: Record<string, any>,
1021
+ * writeScope?: string[]|null,
1022
+ * confirmed?: {checks: string[], protections: string[], openQuestions?: string[], notChecked?: string[]}|null}} o
756
1023
  */
757
- export function authorPrompt({ answers, questions = GREEN_QUESTIONS, facts, listingBlock, lang, verdictType, guards, ownedEnvNames = [], mode = 'tool', catalogue = KIND_CATALOGUE }) {
1024
+ export function authorPrompt({
1025
+ answers, questions = GREEN_QUESTIONS, facts, listingBlock, lang, verdictType, guards,
1026
+ ownedEnvNames = [], mode = 'tool', catalogue = KIND_CATALOGUE, writeScope = null,
1027
+ // PRD item 33 M3 piece 4: ABSENT for every caller that predates the confirm
1028
+ // turn (or ran without `ask`) — byte-identical prompt, nothing added. Present
1029
+ // only when a confirm turn actually ran and the person accepted a plan.
1030
+ confirmed = null,
1031
+ }) {
758
1032
  const statement = CLASS_STATEMENTS[String(verdictType)] ?? null;
759
1033
  if (statement === null) {
760
1034
  throw new Error(`[authorflow] the authoring prompt needs the verdict class the user picked, and "${verdictType}" `
761
1035
  + `composes nothing here — v1 composes ${Object.entries(CLASS_STATEMENTS).filter(([, v]) => v).map(([k]) => k).join(', ')} only`);
762
1036
  }
1037
+ // The signed table's own label for each key (PRD item 33 M3 piece 3's wording
1038
+ // fix), shown beside the question so the composer reads the same Field/Holds
1039
+ // pairing a person sees at the terminal. `verdictType` is already checked
1040
+ // against `CLASS_STATEMENTS` above and that set's keys are exactly
1041
+ // `QUESTION_SETS`'s, so `labelsFor` cannot throw here. A key with no label
1042
+ // (hitl's own fourth question has no signed-table row) is shown with none.
1043
+ const labels = labelsFor(String(verdictType));
763
1044
  const interview = Object.entries(questions)
764
- .map(([n, q]) => `Q${n}. ${q}\nA${n}. ${answers?.[n] ?? '(no answer given)'}`)
1045
+ .map(([n, q]) => `Q${n}${labels[n] ? ` (${labels[n]})` : ''}. ${q}\nA${n}. ${answers?.[n] ?? '(no answer given)'}`)
765
1046
  .join('\n\n');
766
1047
 
767
1048
  const role = `You are composing the close for one job.
@@ -819,6 +1100,11 @@ measure anything.`;
819
1100
  // composer reads that plus the catalogue and works the rest out.
820
1101
  `WHAT THE PERSON DECLARED "DONE" TO MEAN — their answer, not yours\n\n${statement}`,
821
1102
  `THE INTERVIEW — the person's own words\n\n${interview}`,
1103
+ // Q2 IS GONE (PRD item 33 M3 piece 3) — what it used to carry (which files
1104
+ // change, which are read-only) is proven against the machine now, not typed,
1105
+ // and this is where that proof reaches the composer instead of vanishing.
1106
+ ...(writeScope && writeScope.length ? [writeScopeBlock(writeScope)] : []),
1107
+ ...(confirmed ? [confirmedBlock(confirmed)] : []),
822
1108
  `THE FACTS OBJECT — from a read-only survey of the repository\n\n${JSON.stringify(facts, null, 2)}`
823
1109
  + (listingBlock ? `\n\n${listingBlock}` : ''),
824
1110
  // CLASS-SCOPED (softgreen module 3): the composer for a green job is never
@@ -1273,6 +1559,320 @@ async function askDeclaration({ messages, generate, mode, retries, label, book,
1273
1559
  return { ...r, declaration: r.artifact };
1274
1560
  }
1275
1561
 
1562
+ // ── THE CONFIRM TURN (PRD item 33 M3 piece 4) ───────────────────────────────
1563
+ //
1564
+ // The confirm turn drafts a PLAN — checks it will compose, protections
1565
+ // (always-on guards, never named in the goal), and one signed goal sentence —
1566
+ // from the person's own free-text answers plus the read-only survey/listing
1567
+ // the scout or seed-read already paid for, THEN shows it back and lets the
1568
+ // person confirm, fix (up to the 2-round cap, ruling 5), type the goal
1569
+ // themselves, or start over. No hand-authored matcher ever compares the goal
1570
+ // against the checks (ruling 6) — the model states its own plan, in the
1571
+ // model-facing prompt below, and the person is the one judgement that
1572
+ // accepts it.
1573
+
1574
+ export const CONFIRM_TOOL_NAME = 'confirm_plan';
1575
+
1576
+ /** Acknowledgement text the confirm tool's `execute` returns — mirrors
1577
+ * {@link DECLARATION_ACK}'s role: an output channel that records and
1578
+ * acknowledges, takes no action, and ends the call the moment it fires
1579
+ * (`makeLoopGenerate` stops the loop on any tool execute). */
1580
+ export const CONFIRM_ACK = 'plan received';
1581
+
1582
+ /** the confirm channel's own STRUCTURE_INSTRUCTION_TOOL analogue, shown after
1583
+ * a malformed reply so the retry names the ONE required shape rather than
1584
+ * repeating the whole system prompt */
1585
+ export const CONFIRM_STRUCTURE_INSTRUCTION = `Your reply did not deliver exactly one plan through the ${CONFIRM_TOOL_NAME} tool. `
1586
+ + 'Call it exactly once, with the whole plan as its arguments: checks, one goal sentence, anything not covered '
1587
+ + 'by a check, and any genuinely missing questions.';
1588
+
1589
+ /** @type {Record<string, any>} */
1590
+ const CONFIRM_SCHEMA = Object.freeze({
1591
+ type: 'object',
1592
+ properties: {
1593
+ checks: {
1594
+ type: 'array',
1595
+ items: { type: 'string', minLength: 1 },
1596
+ description: 'the mechanical checks you plan to compose, each one traceable to something the Goal or What '
1597
+ + 'success looks like answers actually asked for — never a check invented beyond them',
1598
+ },
1599
+ goal: {
1600
+ type: 'string',
1601
+ minLength: 1,
1602
+ description: 'ONE sentence naming every listed check (never a protection or guard — those are not yours to '
1603
+ + "name) — this becomes the signed job spec's goal",
1604
+ },
1605
+ notChecked: {
1606
+ type: 'array',
1607
+ items: { type: 'string', minLength: 1 },
1608
+ description: 'anything the person asked for (in the Goal, What success looks like, Guardrails, or '
1609
+ + 'worse-than-before answers) that none of your listed checks can verify — named plainly in the person\'s '
1610
+ + 'own words; an empty list means every ask is covered by a listed check',
1611
+ },
1612
+ questions: {
1613
+ type: 'array',
1614
+ items: { type: 'string', minLength: 1 },
1615
+ description: 'anything genuinely missing from the person\'s answers that you need to plan honestly — never '
1616
+ + 'asked to double-check something already answered',
1617
+ },
1618
+ },
1619
+ required: ['checks', 'goal', 'notChecked', 'questions'],
1620
+ additionalProperties: false,
1621
+ });
1622
+
1623
+ /** The output channel. It records and acknowledges; it takes no action.
1624
+ * @param {{calls: any[]}} box */
1625
+ export function confirmTool(box) {
1626
+ return {
1627
+ name: CONFIRM_TOOL_NAME,
1628
+ description: 'Deliver the drafted plan: the checks you will compose, one goal sentence, anything the person '
1629
+ + 'asked for that no check covers, and any genuinely missing questions. Call this exactly once.',
1630
+ parameters: CONFIRM_SCHEMA,
1631
+ execute: async (/** @type {any} */ args) => { box.calls.push(args); return CONFIRM_ACK; },
1632
+ };
1633
+ }
1634
+
1635
+ /** THE CONFIRM CHANNEL — the one this turn uses, over the shared
1636
+ * {@link askStructured} ladder, exactly as {@link declarationChannel} is for
1637
+ * the authoring call. */
1638
+ const confirmChannel = () => ({
1639
+ name: CONFIRM_TOOL_NAME,
1640
+ instruction: CONFIRM_STRUCTURE_INSTRUCTION,
1641
+ textPath: 'plan',
1642
+ tool: (/** @type {{calls: any[]}} */ box) => confirmTool(box),
1643
+ });
1644
+
1645
+ /**
1646
+ * The confirm turn's system prompt (model-facing, registered — a commit
1647
+ * touching this needs Failure/Addresses/Corrects labels, `src/promptregisters.js`).
1648
+ * States ruling 6 ("a genre never adds a check the goal did not ask for") as
1649
+ * an ORDER to the model itself, the same register {@link AUTHOR_SYSTEM} and
1650
+ * {@link CLASS_STATEMENTS} already use, rather than a code matcher over prose.
1651
+ */
1652
+ export const CONFIRM_SYSTEM = 'You read what a NON-ENGINEER answered, plus a read-only survey or listing of their '
1653
+ + 'own repository, and draft a PLAN for a job\'s definition of done: the checks you plan to compose and one '
1654
+ + 'signed goal sentence. You never propose a check the Goal or What-success-looks-like answers did not ask for '
1655
+ + '— a genre never adds a check the goal did not ask for (run mtv8jihy drafted an unasked tsc --strict stage; '
1656
+ + 'that is exactly the mistake this order exists to prevent). The always-on guards (changed-from-seed, '
1657
+ + 'no-suppressions, and every mandatory guard) and the write fence are shown to the person by the SYSTEM, never '
1658
+ + 'by you: you never claim, name, or list a protection or guard of your own — that is not your call to state, and '
1659
+ + 'a protection is never a check and never named in the goal sentence. The goal sentence names every check you '
1660
+ + 'listed and nothing more. For anything the person asked for that none of your listed checks can verify, name it '
1661
+ + 'plainly in `notChecked`, in the person\'s own words — never omit a gap to make the plan look complete; '
1662
+ + 'over-reporting a gap is always the safe direction. Ask a question only where an answer is genuinely missing — '
1663
+ + 'never to double-check something already answered. You cannot read anything and you cannot run anything: '
1664
+ + 'everything you know is in the message you are given.';
1665
+
1666
+ /**
1667
+ * The confirm turn's per-round prompt (model-facing, registered). Shows the
1668
+ * person's own answers with their labels (the signed table's Field/Holds
1669
+ * pairing, same as {@link authorPrompt}'s interview block), the survey or
1670
+ * listing already paid for, the write fence, the detected language, "worse
1671
+ * than before" when the person gave one (repo only), and — on a fix round —
1672
+ * what the person asked to change.
1673
+ * @param {{answers: Record<string|number, string>, questions: Record<string|number, string>,
1674
+ * labels?: Record<string|number, string>, facts?: any, listing?: string|null,
1675
+ * writeScope?: string[]|null, isRepo: boolean, lang: string, worseThanBefore?: string,
1676
+ * fixText?: string|null}} o
1677
+ */
1678
+ export function confirmPrompt({ answers, questions, labels = {}, facts = null, listing = null, writeScope = null, isRepo, lang, worseThanBefore = '', fixText = null }) {
1679
+ const lines = ['THE PERSON\'S OWN ANSWERS (a non-engineer; read exactly what they wrote, invent nothing beyond it):'];
1680
+ for (const k of Object.keys(questions)) {
1681
+ const label = labels[k] ? ` (${labels[k]})` : '';
1682
+ lines.push(`Q${k}${label}. ${questions[k]}`);
1683
+ lines.push(`A${k}. ${answers?.[k] ?? '(no answer)'}`);
1684
+ }
1685
+ if (isRepo && worseThanBefore) {
1686
+ lines.push('', `WORSE THAN BEFORE (a constraint the person named, repo jobs only): ${worseThanBefore}`);
1687
+ }
1688
+ lines.push('', `LANGUAGE: ${lang}`);
1689
+ if (writeScope) lines.push(`WRITE SCOPE (the fence — the run may write only here): ${writeScope.join(', ')}`);
1690
+ if (facts) lines.push('', `READ-ONLY SURVEY:\n${JSON.stringify(facts)}`);
1691
+ if (listing) lines.push('', `SEED LISTING:\n${listing}`);
1692
+ if (fixText) lines.push('', `THE PERSON ASKED FOR A CHANGE: ${fixText}`, 'Revise the plan and resubmit.');
1693
+ return lines.join('\n');
1694
+ }
1695
+
1696
+ /**
1697
+ * ONE confirm-turn round-trip: capStop, then one structured ask over the
1698
+ * confirm channel. Split out of {@link runConfirmTurn} only so the ceiling
1699
+ * check and the ask sit beside each other exactly once.
1700
+ * @param {{convo: any[], generate: Function, mode: 'tool'|'text', book: ReturnType<typeof makeCostBook>, label: string}} o
1701
+ * @returns {Promise<{plan: any, convo: any[], providerError: string|null, red: Red|null, budget: 'cap-halt'|'pricing-red'|null}>}
1702
+ */
1703
+ async function askConfirmPlan({ convo, generate, mode, book, label }) {
1704
+ const halt = book.capStop();
1705
+ if (halt) return { plan: null, convo, providerError: null, red: null, budget: halt };
1706
+ const r = await askStructured({ messages: convo, generate, mode, retries: MAX_STRUCTURE_RETRIES, label, book, channel: confirmChannel() });
1707
+ return { plan: r.artifact, convo: r.convo, providerError: r.providerError, red: r.red, budget: r.budget };
1708
+ }
1709
+
1710
+ /**
1711
+ * THE CONFIRM TURN: the $0 half (worse-than-before for a repo, a language
1712
+ * pick when `lang` carries `detectLanguage`'s own `ambiguous` shape), then up
1713
+ * to 2 PAID rounds (ruling 5) of draft → show → the person's menu pick
1714
+ * (ruling 5 addendum). `ask` is the ONE interactive seam — an async function
1715
+ * from a "what's being asked" descriptor to the person's answer, or `null`
1716
+ * meaning input ended (the same signal `scripts/run-interview.mjs`'s own
1717
+ * `nextLine()` already uses) — never a process exit here, because this is a
1718
+ * library function, not a script. A `null` at ANY ask stops the whole turn as
1719
+ * `confirm-abandoned`, spending nothing beyond whatever rounds already ran.
1720
+ *
1721
+ * D3's shape is enforced by the loop bound alone (`round <= 2`), never a
1722
+ * counter checked after the fact: a "fix" on round 2 returns immediately —
1723
+ * there is no 3rd call to make.
1724
+ *
1725
+ * @param {{verdictType: string, answers: Record<string|number, string>,
1726
+ * questions: Record<string|number, string>, labels?: Record<string|number, string>,
1727
+ * facts?: any, listing?: string|null, writeScope?: string[]|null, isRepo: boolean,
1728
+ * lang: string|{kind: 'ambiguous', candidates: string[], dir: string},
1729
+ * generate: Function, book: ReturnType<typeof makeCostBook>,
1730
+ * ask: (step: {kind: string, [k: string]: any}) => Promise<string|null>,
1731
+ * onPhase?: (phase: string, data?: any) => void, mode?: 'tool'|'text',
1732
+ * worseThanBefore?: string}} o
1733
+ * @returns {Promise<{ok: boolean,
1734
+ * stop: null|'cap-halt'|'pricing-red'|'provider-red'|'artifact-red'|'confirm-abandoned'|'confirm-restart',
1735
+ * rounds: number,
1736
+ * accepted: {goal: string, checks: string[], protections: string[], lang: string,
1737
+ * worseThanBefore: string, openQuestions: string[], notChecked: string[]}|null,
1738
+ * reds: Red[], cost: any}>}
1739
+ */
1740
+ export async function runConfirmTurn({
1741
+ verdictType, answers, questions, labels = {}, facts = null, listing = null, writeScope = null,
1742
+ isRepo, lang, generate, book, ask, onPhase = () => {}, mode = 'tool',
1743
+ // `undefined` (the default) means "ask me" — every S2 caller (nothing
1744
+ // pre-resolves this). A caller that already asked worseThanBefore itself
1745
+ // (`authorCloseForJob`'s own $0 phase, run BEFORE the scout per D7) passes
1746
+ // the resolved string straight through, and this turn asks nothing a
1747
+ // second time. `''` is a legal resolved answer ("nothing beyond
1748
+ // Guardrails") and is NOT the same as "ask me" — only `undefined` is.
1749
+ worseThanBefore: presetWorseThanBefore = undefined,
1750
+ }) {
1751
+ /** @typedef {null|'cap-halt'|'pricing-red'|'provider-red'|'artifact-red'|'confirm-abandoned'|'confirm-restart'} ConfirmStop */
1752
+ /** @returns {{ok: boolean, stop: ConfirmStop, rounds: number, accepted: null, reds: Red[], cost: any}} */
1753
+ const base = () => ({ ok: false, stop: null, rounds: 0, accepted: null, reds: [], cost: book.report() });
1754
+ /** @param {number} rounds @returns {{ok: boolean, stop: ConfirmStop, rounds: number, accepted: null, reds: Red[], cost: any}} */
1755
+ const abandon = (rounds) => ({ ...base(), stop: /** @type {ConfirmStop} */ ('confirm-abandoned'), rounds, cost: book.report() });
1756
+
1757
+ // ── the $0 half, entirely before any token spends (D7) ────────────────────
1758
+ /** @type {string} */
1759
+ let worseThanBefore = '';
1760
+ if (presetWorseThanBefore !== undefined) {
1761
+ worseThanBefore = presetWorseThanBefore;
1762
+ } else if (isRepo) {
1763
+ onPhase('confirm-worse-than-before');
1764
+ const wtb = await ask({ kind: 'worseThanBefore', field: WORSE_THAN_BEFORE_FIELD });
1765
+ if (wtb === null) return abandon(0);
1766
+ worseThanBefore = redactSecrets(String(wtb).trim());
1767
+ }
1768
+
1769
+ /** @type {string} */
1770
+ let resolvedLang = typeof lang === 'string' ? lang : '';
1771
+ if (lang && typeof lang === 'object' && lang.kind === 'ambiguous') {
1772
+ onPhase('confirm-language-pick', { candidates: lang.candidates });
1773
+ const pick = await ask({ kind: 'language', field: LANGUAGE_PICK_FIELD, candidates: lang.candidates });
1774
+ if (pick === null) return abandon(0);
1775
+ resolvedLang = String(pick);
1776
+ }
1777
+
1778
+ // ── up to 2 paid rounds (ruling 5) ─────────────────────────────────────────
1779
+ /** @type {string[]} */
1780
+ const openQuestions = [];
1781
+ /** @type {string|null} */
1782
+ let fixText = null;
1783
+ let convo = [{
1784
+ role: 'user',
1785
+ content: confirmPrompt({
1786
+ answers, questions, labels, facts, listing, writeScope, isRepo, lang: resolvedLang, worseThanBefore,
1787
+ }),
1788
+ }];
1789
+
1790
+ for (let round = 1; round <= 2; round += 1) {
1791
+ if (fixText) {
1792
+ convo = [...convo, { role: 'user', content: confirmPrompt({
1793
+ answers, questions, labels, facts, listing, writeScope, isRepo, lang: resolvedLang, worseThanBefore, fixText,
1794
+ }) }];
1795
+ }
1796
+ onPhase('confirm-round', { round });
1797
+ const label = round === 1 ? 'confirm' : `confirm#${round}`;
1798
+ const r = await askConfirmPlan({ convo, generate, mode, book, label });
1799
+ convo = r.convo;
1800
+
1801
+ if (r.budget) return { ...base(), stop: r.budget, rounds: round - 1, cost: book.report() };
1802
+ if (r.providerError) return { ...base(), stop: /** @type {ConfirmStop} */ ('provider-red'), rounds: round, reds: [{ code: 'provider-red', path: label, detail: redactSecrets(r.providerError) }], cost: book.report() };
1803
+ if (!r.plan) return { ...base(), stop: /** @type {ConfirmStop} */ ('artifact-red'), rounds: round, reds: [/** @type {Red} */ (r.red)], cost: book.report() };
1804
+
1805
+ // "Unpriced is never free" (F6): a plan can arrive structurally fine on a
1806
+ // call whose OWN cost came back unknown. Nothing downstream ever makes
1807
+ // another call to discover that (a "confirm" pick is the last call this
1808
+ // turn ever makes), so it is checked HERE, once, right after the call
1809
+ // that might have caused it — never silently accepted as a signable plan
1810
+ // whose ceiling can no longer be enforced.
1811
+ const postCallHalt = book.capStop();
1812
+ if (postCallHalt) return { ...base(), stop: postCallHalt, rounds: round, cost: book.report() };
1813
+
1814
+ // THE REAL PROTECTIONS (fix #1, run mu0voeo4, 2026-09-14): never the
1815
+ // model's `protections` prose — the schema no longer even asks the model
1816
+ // for one — but the guards `classGuards` will actually compose, plus the
1817
+ // write fence. `notChecked` is the model's own honest gap list, carried
1818
+ // through unchanged.
1819
+ const protections = confirmProtections({ verdictType, lang: resolvedLang, writeScope });
1820
+ const notChecked = [...(r.plan.notChecked ?? [])];
1821
+ const plan = { ...r.plan, protections, notChecked };
1822
+ onPhase('confirm-done', { round, plan });
1823
+ const picked = await ask({ kind: 'menu', field: CONFIRM_MENU, plan });
1824
+ if (picked === null) return abandon(round);
1825
+ if (picked === 'start-over') return { ...base(), stop: /** @type {ConfirmStop} */ ('confirm-restart'), rounds: round, cost: book.report() };
1826
+
1827
+ if (picked === 'confirm') {
1828
+ return {
1829
+ ok: true, stop: null, rounds: round,
1830
+ accepted: {
1831
+ goal: String(r.plan.goal ?? ''), checks: [...(r.plan.checks ?? [])], protections: [...protections],
1832
+ lang: resolvedLang, worseThanBefore, openQuestions: [...openQuestions], notChecked: [...notChecked],
1833
+ },
1834
+ reds: [], cost: book.report(),
1835
+ };
1836
+ }
1837
+
1838
+ if (picked === 'type-goal') {
1839
+ const typed = await ask({ kind: 'goal' });
1840
+ if (typed === null) return abandon(round);
1841
+ return {
1842
+ ok: true, stop: null, rounds: round,
1843
+ accepted: {
1844
+ goal: redactSecrets(String(typed).trim()), checks: [...(r.plan.checks ?? [])], protections: [...protections],
1845
+ lang: resolvedLang, worseThanBefore, openQuestions: [...openQuestions], notChecked: [...notChecked],
1846
+ },
1847
+ reds: [], cost: book.report(),
1848
+ };
1849
+ }
1850
+
1851
+ // picked === 'fix'
1852
+ const fix = await ask({ kind: 'fix' });
1853
+ if (fix === null) return abandon(round);
1854
+ const redactedFix = redactSecrets(String(fix).trim());
1855
+ openQuestions.push(redactedFix);
1856
+ if (round === 2) {
1857
+ // D3: after round 2 a "fix" is passed to the composer verbatim (via
1858
+ // openQuestions, shown at signing) — there is no 3rd call.
1859
+ return {
1860
+ ok: true, stop: null, rounds: round,
1861
+ accepted: {
1862
+ goal: String(r.plan.goal ?? ''), checks: [...(r.plan.checks ?? [])], protections: [...protections],
1863
+ lang: resolvedLang, worseThanBefore, openQuestions: [...openQuestions], notChecked: [...notChecked],
1864
+ },
1865
+ reds: [], cost: book.report(),
1866
+ };
1867
+ }
1868
+ fixText = redactedFix;
1869
+ }
1870
+ // unreachable: the loop above always returns by round 2 — kept only so a
1871
+ // future edit that breaks that invariant fails loudly rather than falling
1872
+ // through to `undefined`.
1873
+ throw new Error('[authorflow] runConfirmTurn fell through its 2-round cap without returning');
1874
+ }
1875
+
1276
1876
  /**
1277
1877
  * THE AUTHORING FLOW.
1278
1878
  *
@@ -1309,12 +1909,32 @@ async function askDeclaration({ messages, generate, mode, retries, label, book,
1309
1909
  * onPhase?: (phase: string, data?: any) => void,
1310
1910
  * onCall?: (call: {label: string, costUsd: number|null, unpricedRounds: number}) => void,
1311
1911
  * maxRevisions?: number, structureRetries?: number,
1312
- * structuredMode?: 'tool'|'text', catalogue?: Record<string, any>}} o
1912
+ * structuredMode?: 'tool'|'text', catalogue?: Record<string, any>, writeScope?: string[]|null,
1913
+ * priorCalls?: {label: string, costUsd: number|null, unpricedRounds: number}[]|null,
1914
+ * priorRaws?: any[]|null,
1915
+ * confirmed?: {checks: string[], protections: string[], openQuestions?: string[], notChecked?: string[]}|null}} o
1313
1916
  */
1314
1917
  export async function authorClose({
1315
1918
  workdir, seedRef, lang, verdictType,
1316
1919
  answers, questions = GREEN_QUESTIONS,
1920
+ // Q2 IS GONE from the numbered interview (PRD item 33 M3 piece 3) — this is
1921
+ // what carries its information into the prompt instead (`writeScopeBlock`).
1922
+ // Optional and defaulted to `null` so every caller that predates the reshape
1923
+ // still runs byte-identical; a REAL caller (`authorCloseForJob`) always
1924
+ // passes the repo's proven `writeScope`.
1925
+ writeScope = null,
1317
1926
  scout, listing = null,
1927
+ // PRD item 33 M3 piece 4: the confirm turn's own paid calls — absorbed
1928
+ // BESIDE the scout's, below, so the ONE ceiling this run advertised is the
1929
+ // one it enforces (the standing hard line's money form): a confirm turn
1930
+ // that already spent half the ceiling must not let the author call spend
1931
+ // as if it never happened. `null`/absent is every caller that ran no
1932
+ // confirm turn — byte-identical to today.
1933
+ priorCalls = null, priorRaws = null,
1934
+ // The confirm turn's ACCEPTED plan (`runConfirmTurn`'s `accepted`), fed
1935
+ // straight into `authorPrompt`'s `confirmedBlock`. `null` is every caller
1936
+ // that ran no confirm turn — the prompt is then byte-identical to today.
1937
+ confirmed = null,
1318
1938
  generate, seedReadFn = runSeedReadStages, closeCtx = {},
1319
1939
  ceilingUsd = null,
1320
1940
  // The two REPORTING seams, defaulted to nothing so every existing caller is
@@ -1351,6 +1971,7 @@ export async function authorClose({
1351
1971
  // that spends nothing more: run mslhn707 refused at the $0 preflight below,
1352
1972
  // and the survey text that would have said WHY died with the process.
1353
1973
  book.absorb(scout?.calls ?? [], scout?.raws ?? []);
1974
+ book.absorb(priorCalls ?? [], priorRaws ?? []);
1354
1975
  /** @type {any} */
1355
1976
  const base = {
1356
1977
  ok: false, declaration: null, seedRead: null, iterations: [], reds: [], cost: book.report(),
@@ -1487,7 +2108,7 @@ export async function authorClose({
1487
2108
  // ── the grounded loop ─────────────────────────────────────────────────────
1488
2109
  const prompt = authorPrompt({
1489
2110
  answers, questions, facts, listingBlock: /** @type {string} */ (seeds.block),
1490
- lang, verdictType, guards, ownedEnvNames, mode: structuredMode, catalogue,
2111
+ lang, verdictType, guards, ownedEnvNames, mode: structuredMode, catalogue, writeScope, confirmed,
1491
2112
  });
1492
2113
  /** @type {any[]} */
1493
2114
  let messages = [{ role: 'user', content: prompt }];