bareloop 0.25.1 → 0.27.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
@@ -83,7 +83,7 @@ import {
83
83
  validateDeclaration, envCapableKind, classMenu, VERDICT_CLASSES, LIVE_CLASSES, MENU_CLASSES,
84
84
  } from './authoring.js';
85
85
  import { buildSeedListing, cleanEntry, SURVEY_CAUSES, AUTHOR_CALL_TIMEOUT_MS } from './authorscout.js';
86
- import { extractArtifact, priceOf, scrubRaw, tallyCalls, capStop } from './text.js';
86
+ import { extractArtifact, priceOf, scrubRaw, tallyCalls, capStop, callCasualty } from './text.js';
87
87
  import { redactSecrets } from './validate.js';
88
88
 
89
89
  const require = createRequire(import.meta.url);
@@ -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,146 @@ 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
+ * F175's open half (docs/logs/FINDINGS.md, run mu0voeo4), hamr's ruling
474
+ * (2026-09-16): "Confirm" and "Type the goal yourself" may not bypass a
475
+ * `questions` entry the model itself raised on the plan being accepted — the
476
+ * person answers it inline, one question at a time, with NO extra model
477
+ * call (the round cap and D3 are untouched). This is the `field` handed to
478
+ * `ask({kind: 'answer', ...})` for each forced question — `question`,
479
+ * `index` (1-based) and `total` ride alongside on the step itself, not on
480
+ * this frozen constant, because they differ per question within one turn.
481
+ */
482
+ export const ANSWER_QUESTION_FIELD = Object.freeze({
483
+ id: 'answerQuestion',
484
+ kind: 'mechanical',
485
+ label: 'Answer',
486
+ prompt: 'The plan raised a question it could not resolve on its own. Answer it before the plan can be signed '
487
+ + '("Start over" or "Fix" are the ways out if you cannot answer it here).',
488
+ });
489
+
490
+ /**
491
+ * THE CONFIRM TURN'S MENU — a structured CHOICE the person picks from after
492
+ * each round, never free text matched against a pattern (ruling 6's "no
493
+ * hand-authored check matcher" reasoning applies here too: the person's own
494
+ * reply is read as one of these four keys, never parsed for intent).
495
+ * confirm — sign the plan and the drafted goal sentence as shown.
496
+ * fix — free text describing what to change; feeds round r+1, up
497
+ * to the 2-round cap (ruling 5); after round 2 a "fix" is
498
+ * passed to the composer verbatim and shown at signing as an
499
+ * open question rather than spending a 3rd call (D3).
500
+ * type-goal — the person's own sentence REPLACES the drafted goal,
501
+ * verbatim (redacted); everything else in the plan stands.
502
+ * start-over — abandon this confirm turn; the person reruns the
503
+ * interview from the beginning (`confirm-restart`).
504
+ * @type {Record<'confirm'|'fix'|'type-goal'|'start-over', string>}
505
+ */
506
+ export const CONFIRM_MENU = Object.freeze({
507
+ confirm: 'Confirm — sign this plan and goal as shown',
508
+ fix: 'Fix — describe what to change',
509
+ 'type-goal': 'Type the goal sentence yourself',
510
+ 'start-over': 'Start over — rerun the interview from the beginning',
511
+ });
512
+
513
+ /**
514
+ * PLAIN-ENGLISH lines for the guards `classGuards` can compose, keyed by
515
+ * guard NAME — frozen beside {@link CONFIRM_MENU} because both are constants
516
+ * the confirm turn shows a person verbatim. One entry per guard that exists
517
+ * in `MECHANICAL_GUARDS` (`src/authoring.js`); {@link confirmProtections}
518
+ * throws if a guard `classGuards` hands back has no line here, so a battery
519
+ * that grows a guard fails loudly instead of showing a blank description.
520
+ * @type {Record<string, string>}
521
+ */
522
+ export const GUARD_DESCRIPTIONS = Object.freeze({
523
+ 'changed-from-seed': 'the run must actually change at least one file in scope — a close that reports done '
524
+ + 'without touching anything is refused',
525
+ 'no-suppressions': 'the run may not silence a checker instead of fixing what it flags (things like @ts-ignore, '
526
+ + 'eslint-disable, or # type: ignore) — none of those may appear in the diff',
527
+ });
528
+
529
+ /**
530
+ * THE REAL PROTECTIONS a person is shown, and what {@link runConfirmTurn}
531
+ * records as `accepted.protections` — fixes finding #1 from live run
532
+ * mu0voeo4 (2026-09-14): the confirm turn used to show whatever prose the
533
+ * MODEL invented for `protections`, and that run's model invented a
534
+ * "behavior-preservation guard" this build cannot check — no kind in the
535
+ * catalogue can verify runtime behaviour, and the author call's own note
536
+ * elsewhere says outright that behaviour-preservation is not composed here.
537
+ * This reads the SAME guards `classGuards` will actually compose into the
538
+ * close — one source, never a second hand-typed list — rendered through the
539
+ * frozen line in {@link GUARD_DESCRIPTIONS}, plus the write fence (a real,
540
+ * signed fence — {@link writeScopeBlock}'s own subject) when one is set.
541
+ * @param {{verdictType: string, lang: string, writeScope?: string[]|null}} o
542
+ * @returns {string[]}
543
+ */
544
+ export function confirmProtections({ verdictType, lang, writeScope = null }) {
545
+ const guards = classGuards({ verdictType, lang });
546
+ const lines = guards.map((g) => {
547
+ const desc = GUARD_DESCRIPTIONS[g.name];
548
+ if (!desc) {
549
+ throw new Error(`[authorflow] guard "${g.name}" has no line in GUARD_DESCRIPTIONS — every real guard shown to `
550
+ + 'a person needs one, frozen beside CONFIRM_MENU');
551
+ }
552
+ return `${g.name} — ${desc}`;
553
+ });
554
+ if (Array.isArray(writeScope) && writeScope.length > 0) {
555
+ lines.push(`write fence — the run may only change files matching: ${writeScope.join(', ')}`);
556
+ }
557
+ return lines;
558
+ }
345
559
 
346
560
  export const AUTHOR_SYSTEM = 'You compose the DEFINITION OF DONE for an automated job: a declaration over a fixed '
347
561
  + 'catalogue of stage kinds whose implementations already exist. You never write code, a script, a shell fragment, '
@@ -737,6 +951,81 @@ export function lawsBlock() {
737
951
  sounds like it should be called.`;
738
952
  }
739
953
 
954
+ /**
955
+ * WHAT MAY CHANGE, AND WHAT IS ONLY READ — replaces the old Q2 (PRD item 33 M3
956
+ * piece 3). Q2 used to ask the person, in prose, which files change and which
957
+ * are read-only; that decision is now Destination, PROVEN against the
958
+ * repository rather than typed (`docs/product/ITEM33-BUILD.md` "M3", ruling 2),
959
+ * and for a repo source Destination's answer IS the signed `writeScope` fence.
960
+ * This states that fact to the composer instead of letting it vanish along
961
+ * with Q2 — the same "nothing the composer received before may silently
962
+ * disappear" rule `applyGenreEnv` already keeps for a dropped env var.
963
+ *
964
+ * THROWS on an empty `writeScope`: Destination is a MANDATORY fence
965
+ * (`run-interview.mjs` refuses an empty answer at capture), so a caller
966
+ * reaching this with nothing to state is a bareloop wiring bug, not a job with
967
+ * no fence — a composer told it may change everything guards nothing.
968
+ * @param {string[]} writeScope
969
+ */
970
+ export function writeScopeBlock(writeScope) {
971
+ if (!Array.isArray(writeScope) || writeScope.length === 0) {
972
+ throw new Error('[authorflow] writeScopeBlock needs a non-empty writeScope — Destination is the fence and a '
973
+ + 'composer call with nothing to state is a bareloop wiring bug, not a job authored with no fence');
974
+ }
975
+ return `WHAT MAY CHANGE, AND WHAT IS ONLY READ\n\n`
976
+ + 'The person\'s own Destination answer is a write fence, proven against the repository rather than typed as '
977
+ + 'prose. You may only change files matching one of these patterns:\n\n'
978
+ + `${writeScope.map((p) => ` - ${p}`).join('\n')}\n\n`
979
+ + 'Everything else in the facts object and the listing below is READ-ONLY: you may look at it, and any command '
980
+ + 'you run may read it, but no stage you declare may write, move or delete it.';
981
+ }
982
+
983
+ /**
984
+ * THE CONFIRMED-PLAN BLOCK (PRD item 33 M3 piece 4) — present only when the
985
+ * person already ran a confirm turn (`runConfirmTurn`'s `accepted`). States
986
+ * the composition law as an ORDER over the ACCEPTED checks, exactly the
987
+ * pattern {@link writeScopeBlock} already is for the fence: "compose these
988
+ * checks and no other" rather than a code matcher over the goal prose
989
+ * (ruling 6). Protections are restated as unchanged, never as checks to add.
990
+ * Open questions the person's own fix rounds left unresolved (D3) are named
991
+ * so the composer states them in notes rather than silently deciding them.
992
+ * `notChecked` (fix #1, run mu0voeo4) is anything the person asked for that
993
+ * no listed check can verify — shown so the composer names the gap in its
994
+ * own notes rather than the plan quietly reading as if everything asked for
995
+ * is covered.
996
+ * `answeredQuestions` (F175's open half, run mu0voeo4, hamr's 2026-09-16
997
+ * ruling) is every `Q:/A:` pair the person answered inline when a "Confirm"
998
+ * or "Type the goal yourself" pick forced a raised question before
999
+ * acceptance — BINDING, never re-decided by the composer, and a DIFFERENT
1000
+ * meaning from `openQuestions` (which means "could not resolve"): the two
1001
+ * never merge.
1002
+ * @param {{checks: string[], protections: string[], openQuestions?: string[], notChecked?: string[],
1003
+ * answeredQuestions?: string[]}} confirmed
1004
+ */
1005
+ export function confirmedBlock(confirmed) {
1006
+ const checks = confirmed.checks ?? [];
1007
+ const protections = confirmed.protections ?? [];
1008
+ const openQuestions = confirmed.openQuestions ?? [];
1009
+ const notChecked = confirmed.notChecked ?? [];
1010
+ const answeredQuestions = confirmed.answeredQuestions ?? [];
1011
+ return 'THE CONFIRMED PLAN — the person already saw and confirmed this in the confirm turn\n\n'
1012
+ + 'Compose stages for these checks and no other — a genre never adds a check the goal did not ask for, and '
1013
+ + 'neither do you:\n\n'
1014
+ + `${checks.map((c) => ` - ${c}`).join('\n') || ' (none)'}\n\n`
1015
+ + 'These protections are already always-on and unchanged by this plan — never compose one of these as a check:\n\n'
1016
+ + `${protections.map((p) => ` - ${p}`).join('\n') || ' (none)'}\n\n`
1017
+ + (notChecked.length
1018
+ ? 'THE PERSON ASKED FOR THESE, BUT NOTHING CHECKS THEM (state these in your notes — never silently claim they '
1019
+ + `are covered):\n\n${notChecked.map((n) => ` - ${n}`).join('\n')}\n\n`
1020
+ : '')
1021
+ + (answeredQuestions.length
1022
+ ? `QUESTIONS THE PERSON ANSWERED — these answers are binding; compose to them, never re-decide them:\n\n${answeredQuestions.map((a) => ` - ${a}`).join('\n')}\n\n`
1023
+ : '')
1024
+ + (openQuestions.length
1025
+ ? `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')}`
1026
+ : 'The confirm turn left no open questions.');
1027
+ }
1028
+
740
1029
  /**
741
1030
  * The whole authoring prompt, as one string.
742
1031
  *
@@ -749,19 +1038,40 @@ export function lawsBlock() {
749
1038
  * the one thing that tells the composer what DONE is allowed to mean here; a
750
1039
  * defaulted class would be the prompt answering a question the user was asked.
751
1040
  *
1041
+ * `writeScope` carries what the old Q2 used to (PRD item 33 M3 piece 3): Q2 is
1042
+ * gone from the numbered interview, so what may change and what is read-only
1043
+ * reaches the composer through {@link writeScopeBlock} instead, fed from
1044
+ * Destination's own proven fence — never silently dropped.
1045
+ *
752
1046
  * @param {{answers: Record<string|number, string>, questions?: Record<string|number, string>,
753
1047
  * facts: any, listingBlock: string, lang: string, verdictType: string,
754
1048
  * guards: {name: string, kind: string, params: Record<string, any>, fill: string[]}[],
755
- * ownedEnvNames?: string[], mode?: 'tool'|'text', catalogue?: Record<string, any>}} o
1049
+ * ownedEnvNames?: string[], mode?: 'tool'|'text', catalogue?: Record<string, any>,
1050
+ * writeScope?: string[]|null,
1051
+ * confirmed?: {checks: string[], protections: string[], openQuestions?: string[], notChecked?: string[], answeredQuestions?: string[]}|null}} o
756
1052
  */
757
- export function authorPrompt({ answers, questions = GREEN_QUESTIONS, facts, listingBlock, lang, verdictType, guards, ownedEnvNames = [], mode = 'tool', catalogue = KIND_CATALOGUE }) {
1053
+ export function authorPrompt({
1054
+ answers, questions = GREEN_QUESTIONS, facts, listingBlock, lang, verdictType, guards,
1055
+ ownedEnvNames = [], mode = 'tool', catalogue = KIND_CATALOGUE, writeScope = null,
1056
+ // PRD item 33 M3 piece 4: ABSENT for every caller that predates the confirm
1057
+ // turn (or ran without `ask`) — byte-identical prompt, nothing added. Present
1058
+ // only when a confirm turn actually ran and the person accepted a plan.
1059
+ confirmed = null,
1060
+ }) {
758
1061
  const statement = CLASS_STATEMENTS[String(verdictType)] ?? null;
759
1062
  if (statement === null) {
760
1063
  throw new Error(`[authorflow] the authoring prompt needs the verdict class the user picked, and "${verdictType}" `
761
1064
  + `composes nothing here — v1 composes ${Object.entries(CLASS_STATEMENTS).filter(([, v]) => v).map(([k]) => k).join(', ')} only`);
762
1065
  }
1066
+ // The signed table's own label for each key (PRD item 33 M3 piece 3's wording
1067
+ // fix), shown beside the question so the composer reads the same Field/Holds
1068
+ // pairing a person sees at the terminal. `verdictType` is already checked
1069
+ // against `CLASS_STATEMENTS` above and that set's keys are exactly
1070
+ // `QUESTION_SETS`'s, so `labelsFor` cannot throw here. A key with no label
1071
+ // (hitl's own fourth question has no signed-table row) is shown with none.
1072
+ const labels = labelsFor(String(verdictType));
763
1073
  const interview = Object.entries(questions)
764
- .map(([n, q]) => `Q${n}. ${q}\nA${n}. ${answers?.[n] ?? '(no answer given)'}`)
1074
+ .map(([n, q]) => `Q${n}${labels[n] ? ` (${labels[n]})` : ''}. ${q}\nA${n}. ${answers?.[n] ?? '(no answer given)'}`)
765
1075
  .join('\n\n');
766
1076
 
767
1077
  const role = `You are composing the close for one job.
@@ -819,6 +1129,11 @@ measure anything.`;
819
1129
  // composer reads that plus the catalogue and works the rest out.
820
1130
  `WHAT THE PERSON DECLARED "DONE" TO MEAN — their answer, not yours\n\n${statement}`,
821
1131
  `THE INTERVIEW — the person's own words\n\n${interview}`,
1132
+ // Q2 IS GONE (PRD item 33 M3 piece 3) — what it used to carry (which files
1133
+ // change, which are read-only) is proven against the machine now, not typed,
1134
+ // and this is where that proof reaches the composer instead of vanishing.
1135
+ ...(writeScope && writeScope.length ? [writeScopeBlock(writeScope)] : []),
1136
+ ...(confirmed ? [confirmedBlock(confirmed)] : []),
822
1137
  `THE FACTS OBJECT — from a read-only survey of the repository\n\n${JSON.stringify(facts, null, 2)}`
823
1138
  + (listingBlock ? `\n\n${listingBlock}` : ''),
824
1139
  // CLASS-SCOPED (softgreen module 3): the composer for a green job is never
@@ -1152,6 +1467,10 @@ export function makeLoopGenerate(provider, { system = AUTHOR_SYSTEM, maxTokens =
1152
1467
  // answers hangs the process forever otherwise. `...opts` still spreads AFTER
1153
1468
  // it, so a caller that ever needs a tighter number for one call may pass it —
1154
1469
  // tighten-only, and nothing does today.
1470
+ // F179 — bare-agent 0.43.0's OpenAIProvider/Ollama generate() no longer
1471
+ // throws on a malformed tool-call arguments string (BA-27): it returns the
1472
+ // round priced with `toolCalls: []` plus its own `malformedToolCall` marker,
1473
+ // which `Loop.run` surfaces unchanged on its return. No local shim needed.
1155
1474
  return loop.run(messages, wired, { cacheMessages: true, maxTokens, timeoutMs: AUTHOR_CALL_TIMEOUT_MS, ...opts });
1156
1475
  };
1157
1476
  }
@@ -1207,7 +1526,28 @@ export async function askStructured({ messages, generate, mode, retries, label,
1207
1526
  // two must offer one set of kinds, or the composer reads about a kind it
1208
1527
  // cannot call — or worse, calls one the ceiling will refuse after we paid.
1209
1528
  const tools = mode === 'tool' ? [channel.tool(box)] : [];
1210
- const r = await generate(convo, tools, {});
1529
+ // F179/F180 — bare-agent 0.42.0's OpenAIProvider.generate used to THROW
1530
+ // mid-call (a raw SyntaxError off a malformed tool-call arguments string,
1531
+ // parsed with no try/catch) rather than resolve with `{error}` the way a
1532
+ // settled provider-shape casualty normally does; 0.43.0 fixed that class
1533
+ // upstream (BA-27), so `callCasualty` no longer admits a SyntaxError at
1534
+ // all (its branch was dead code and was removed). This try/catch stays
1535
+ // for the class it was ALSO built to cover: `callCasualty` is the SAME
1536
+ // predicate `authorscout.js`'s `settled` uses for its idle-timeout seam
1537
+ // (src/text.js, beside `priceOf`) — a HaltError or an unrecognised throw
1538
+ // re-raises unchanged; only an admitted casualty class lands here as the
1539
+ // same `{error}` shape the resolved-error path already books and returns
1540
+ // below.
1541
+ let r;
1542
+ try {
1543
+ r = await generate(convo, tools, {});
1544
+ } catch (e) {
1545
+ const reason = callCasualty(e);
1546
+ if (reason === null) throw e;
1547
+ attempts += 1;
1548
+ book.add(attempt === 0 ? label : `${label}#${attempt + 1}`, { error: reason }, attempts);
1549
+ return { artifact: null, attempts, convo, raw: '', providerError: reason, red: null, budget: null };
1550
+ }
1211
1551
  attempts += 1;
1212
1552
  book.add(attempt === 0 ? label : `${label}#${attempt + 1}`, r, attempts);
1213
1553
  const raw = redactSecrets(String(r?.text ?? ''));
@@ -1217,15 +1557,43 @@ export async function askStructured({ messages, generate, mode, retries, label,
1217
1557
  }
1218
1558
 
1219
1559
  if (mode === 'tool') {
1220
- if (box.calls.length === 1) return { artifact: box.calls[0], attempts, convo, raw, providerError: null, red: null, budget: null };
1221
- red = {
1222
- code: 'artifact-red',
1223
- path: channel.name,
1224
- detail: box.calls.length === 0
1225
- ? `the reply delivered no ${channel.name} call — structure is enforced, prose is never parsed`
1226
- : `the reply delivered ${box.calls.length} ${channel.name} calls; exactly one is expected`,
1227
- axis: box.calls.length === 0 ? 'no-declaration-tool-call' : 'multiple-declaration-tool-calls',
1228
- };
1560
+ // F179 (2026-09-15 ruling) — retry, never repair. bare-agent 0.43.0
1561
+ // (BA-27) already priced this round on the real usage and returns it
1562
+ // with `toolCalls: []` plus its own `malformedToolCall` marker instead
1563
+ // of throwing; this ladder's EXISTING malformed-emission retry (below)
1564
+ // is the whole mechanism — no new cap, no second pricing path, nothing
1565
+ // here ever re-parses or edits the model's arguments. The raw malformed
1566
+ // string itself is never persisted, only the parser's own error
1567
+ // message, scrubbed like every other model-authored text on this trail.
1568
+ //
1569
+ // Checked BEFORE the `box.calls.length === 1` accept and REGARDLESS of
1570
+ // how many calls survived (0 or more — bare-agent's `parseToolCalls` is
1571
+ // itself all-or-nothing, BA-27, so a real provider always returns 0 on
1572
+ // a malformed round): before this fix, a two-call reply was ALWAYS the
1573
+ // 'multiple-declaration-tool-calls' red, never an accept, so a
1574
+ // malformed call riding alongside a valid one must not widen what this
1575
+ // ladder takes from a malformed reply. Nothing from a malformed reply
1576
+ // is ever accepted.
1577
+ if (r?.malformedToolCall) {
1578
+ red = {
1579
+ code: 'artifact-red',
1580
+ path: channel.name,
1581
+ detail: `the reply's ${channel.name} call arguments were not valid JSON `
1582
+ + `(${redactSecrets(String(r.malformedToolCall.error))}) — nothing is repaired; the call must be re-sent whole`,
1583
+ axis: 'malformed-tool-call-arguments',
1584
+ };
1585
+ } else if (box.calls.length === 1) {
1586
+ return { artifact: box.calls[0], attempts, convo, raw, providerError: null, red: null, budget: null };
1587
+ } else {
1588
+ red = {
1589
+ code: 'artifact-red',
1590
+ path: channel.name,
1591
+ detail: box.calls.length === 0
1592
+ ? `the reply delivered no ${channel.name} call — structure is enforced, prose is never parsed`
1593
+ : `the reply delivered ${box.calls.length} ${channel.name} calls; exactly one is expected`,
1594
+ axis: box.calls.length === 0 ? 'no-declaration-tool-call' : 'multiple-declaration-tool-calls',
1595
+ };
1596
+ }
1229
1597
  } else {
1230
1598
  // THE FALLBACK PATH — providers without tool mode only. The ONE shipped
1231
1599
  // parser, never a second one.
@@ -1273,6 +1641,398 @@ async function askDeclaration({ messages, generate, mode, retries, label, book,
1273
1641
  return { ...r, declaration: r.artifact };
1274
1642
  }
1275
1643
 
1644
+ // ── THE CONFIRM TURN (PRD item 33 M3 piece 4) ───────────────────────────────
1645
+ //
1646
+ // The confirm turn drafts a PLAN — checks it will compose, protections
1647
+ // (always-on guards, never named in the goal), and one signed goal sentence —
1648
+ // from the person's own free-text answers plus the read-only survey/listing
1649
+ // the scout or seed-read already paid for, THEN shows it back and lets the
1650
+ // person confirm, fix (up to the 2-round cap, ruling 5), type the goal
1651
+ // themselves, or start over. No hand-authored matcher ever compares the goal
1652
+ // against the checks (ruling 6) — the model states its own plan, in the
1653
+ // model-facing prompt below, and the person is the one judgement that
1654
+ // accepts it.
1655
+
1656
+ export const CONFIRM_TOOL_NAME = 'confirm_plan';
1657
+
1658
+ /** Acknowledgement text the confirm tool's `execute` returns — mirrors
1659
+ * {@link DECLARATION_ACK}'s role: an output channel that records and
1660
+ * acknowledges, takes no action, and ends the call the moment it fires
1661
+ * (`makeLoopGenerate` stops the loop on any tool execute). */
1662
+ export const CONFIRM_ACK = 'plan received';
1663
+
1664
+ /** the confirm channel's own STRUCTURE_INSTRUCTION_TOOL analogue, shown after
1665
+ * a malformed reply so the retry names the ONE required shape rather than
1666
+ * repeating the whole system prompt */
1667
+ export const CONFIRM_STRUCTURE_INSTRUCTION = `Your reply did not deliver exactly one plan through the ${CONFIRM_TOOL_NAME} tool. `
1668
+ + 'Call it exactly once, with the whole plan as its arguments: checks, one goal sentence, anything not covered '
1669
+ + 'by a check, and any genuinely missing questions.';
1670
+
1671
+ /** @type {Record<string, any>} */
1672
+ const CONFIRM_SCHEMA = Object.freeze({
1673
+ type: 'object',
1674
+ properties: {
1675
+ checks: {
1676
+ type: 'array',
1677
+ items: { type: 'string', minLength: 1 },
1678
+ description: 'the mechanical checks you plan to compose, each one traceable to something the Goal or What '
1679
+ + 'success looks like answers actually asked for — never a check invented beyond them',
1680
+ },
1681
+ goal: {
1682
+ type: 'string',
1683
+ minLength: 1,
1684
+ description: 'ONE sentence naming every listed check (never a protection or guard — those are not yours to '
1685
+ + "name) — this becomes the signed job spec's goal",
1686
+ },
1687
+ notChecked: {
1688
+ type: 'array',
1689
+ items: { type: 'string', minLength: 1 },
1690
+ description: 'anything the person asked for (in the Goal, What success looks like, Guardrails, or '
1691
+ + 'worse-than-before answers) that none of your listed checks can verify — named plainly in the person\'s '
1692
+ + 'own words; an empty list means every ask is covered by a listed check',
1693
+ },
1694
+ questions: {
1695
+ type: 'array',
1696
+ items: { type: 'string', minLength: 1 },
1697
+ description: 'anything genuinely missing from the person\'s answers that you need to plan honestly — never '
1698
+ + 'asked to double-check something already answered',
1699
+ },
1700
+ },
1701
+ required: ['checks', 'goal', 'notChecked', 'questions'],
1702
+ additionalProperties: false,
1703
+ });
1704
+
1705
+ /** The output channel. It records and acknowledges; it takes no action.
1706
+ * @param {{calls: any[]}} box */
1707
+ export function confirmTool(box) {
1708
+ return {
1709
+ name: CONFIRM_TOOL_NAME,
1710
+ description: 'Deliver the drafted plan: the checks you will compose, one goal sentence, anything the person '
1711
+ + 'asked for that no check covers, and any genuinely missing questions. Call this exactly once.',
1712
+ parameters: CONFIRM_SCHEMA,
1713
+ execute: async (/** @type {any} */ args) => { box.calls.push(args); return CONFIRM_ACK; },
1714
+ };
1715
+ }
1716
+
1717
+ /** THE CONFIRM CHANNEL — the one this turn uses, over the shared
1718
+ * {@link askStructured} ladder, exactly as {@link declarationChannel} is for
1719
+ * the authoring call. */
1720
+ const confirmChannel = () => ({
1721
+ name: CONFIRM_TOOL_NAME,
1722
+ instruction: CONFIRM_STRUCTURE_INSTRUCTION,
1723
+ textPath: 'plan',
1724
+ tool: (/** @type {{calls: any[]}} */ box) => confirmTool(box),
1725
+ });
1726
+
1727
+ /**
1728
+ * The confirm turn's system prompt (model-facing, registered — a commit
1729
+ * touching this needs Failure/Addresses/Corrects labels, `src/promptregisters.js`).
1730
+ * States ruling 6 ("a genre never adds a check the goal did not ask for") as
1731
+ * an ORDER to the model itself, the same register {@link AUTHOR_SYSTEM} and
1732
+ * {@link CLASS_STATEMENTS} already use, rather than a code matcher over prose.
1733
+ */
1734
+ export const CONFIRM_SYSTEM = 'You read what a NON-ENGINEER answered, plus a read-only survey or listing of their '
1735
+ + 'own repository, and draft a PLAN for a job\'s definition of done: the checks you plan to compose and one '
1736
+ + 'signed goal sentence. You never propose a check the Goal or What-success-looks-like answers did not ask for '
1737
+ + '— a genre never adds a check the goal did not ask for (run mtv8jihy drafted an unasked tsc --strict stage; '
1738
+ + 'that is exactly the mistake this order exists to prevent). The always-on guards (changed-from-seed, '
1739
+ + 'no-suppressions, and every mandatory guard) and the write fence are shown to the person by the SYSTEM, never '
1740
+ + 'by you: you never claim, name, or list a protection or guard of your own — that is not your call to state, and '
1741
+ + 'a protection is never a check and never named in the goal sentence. The goal sentence names every check you '
1742
+ + 'listed and nothing more. For anything the person asked for that none of your listed checks can verify, name it '
1743
+ + 'plainly in `notChecked`, in the person\'s own words — never omit a gap to make the plan look complete; '
1744
+ + 'over-reporting a gap is always the safe direction. Before you name anything in `notChecked`, check it against '
1745
+ + 'the ALREADY COVERED list shown to you in the message below (the real guards and write fence this run will '
1746
+ + 'apply) — anything already covered there never belongs in `notChecked`, even when the person asked for it in '
1747
+ + 'their own words (runs mu2bjmed and mu2qmept both wrongly claimed the no-suppressions guard and the write fence '
1748
+ + 'were missing when both were already enforced; check the list before writing the claim). Ask a question only '
1749
+ + 'where an answer is genuinely missing — never to double-check something already answered. You cannot read '
1750
+ + 'anything and you cannot run anything: everything you know is in the message you are given.';
1751
+
1752
+ /**
1753
+ * The confirm turn's per-round prompt (model-facing, registered). Shows the
1754
+ * person's own answers with their labels (the signed table's Field/Holds
1755
+ * pairing, same as {@link authorPrompt}'s interview block), the survey or
1756
+ * listing already paid for, the write fence, the detected language, "worse
1757
+ * than before" when the person gave one (repo only), and — on a fix round —
1758
+ * what the person asked to change.
1759
+ * F183 (2026-09-16, ruling A, run mu2qmept): also carries the REAL,
1760
+ * code-derived `protections` list ({@link confirmProtections} — the same
1761
+ * guards the close will actually compose plus the real write fence, never a
1762
+ * second hand-typed list) as an ALREADY COVERED block, with the instruction
1763
+ * that anything on it never belongs in `notChecked`. Twice live (mu2bjmed,
1764
+ * mu2qmept) the model claimed a guard or the write fence was missing while
1765
+ * blind to what was actually enforced — this is the fix: show it the facts
1766
+ * before it drafts, never hand-write a matcher over its answer.
1767
+ * @param {{answers: Record<string|number, string>, questions: Record<string|number, string>,
1768
+ * labels?: Record<string|number, string>, facts?: any, listing?: string|null,
1769
+ * writeScope?: string[]|null, isRepo: boolean, lang: string, worseThanBefore?: string,
1770
+ * fixText?: string|null, protections?: string[]}} o
1771
+ */
1772
+ export function confirmPrompt({ answers, questions, labels = {}, facts = null, listing = null, writeScope = null, isRepo, lang, worseThanBefore = '', fixText = null, protections = [] }) {
1773
+ const lines = ['THE PERSON\'S OWN ANSWERS (a non-engineer; read exactly what they wrote, invent nothing beyond it):'];
1774
+ for (const k of Object.keys(questions)) {
1775
+ const label = labels[k] ? ` (${labels[k]})` : '';
1776
+ lines.push(`Q${k}${label}. ${questions[k]}`);
1777
+ lines.push(`A${k}. ${answers?.[k] ?? '(no answer)'}`);
1778
+ }
1779
+ if (isRepo && worseThanBefore) {
1780
+ lines.push('', `WORSE THAN BEFORE (a constraint the person named, repo jobs only): ${worseThanBefore}`);
1781
+ }
1782
+ lines.push('', `LANGUAGE: ${lang}`);
1783
+ if (writeScope) lines.push(`WRITE SCOPE (the fence — the run may write only here): ${writeScope.join(', ')}`);
1784
+ if (protections.length > 0) {
1785
+ lines.push(
1786
+ '',
1787
+ 'ALREADY COVERED — these are real, always-on, and enforced by this run regardless of what you compose. '
1788
+ + 'Never list anything already covered here in `notChecked`, even if the person asked for it in their own '
1789
+ + 'words:',
1790
+ ...protections.map((p) => ` - ${p}`),
1791
+ );
1792
+ }
1793
+ if (facts) lines.push('', `READ-ONLY SURVEY:\n${JSON.stringify(facts)}`);
1794
+ if (listing) lines.push('', `SEED LISTING:\n${listing}`);
1795
+ if (fixText) lines.push('', `THE PERSON ASKED FOR A CHANGE: ${fixText}`, 'Revise the plan and resubmit.');
1796
+ return lines.join('\n');
1797
+ }
1798
+
1799
+ /**
1800
+ * ONE confirm-turn round-trip: capStop, then one structured ask over the
1801
+ * confirm channel. Split out of {@link runConfirmTurn} only so the ceiling
1802
+ * check and the ask sit beside each other exactly once.
1803
+ * @param {{convo: any[], generate: Function, mode: 'tool'|'text', book: ReturnType<typeof makeCostBook>, label: string}} o
1804
+ * @returns {Promise<{plan: any, convo: any[], providerError: string|null, red: Red|null, budget: 'cap-halt'|'pricing-red'|null}>}
1805
+ */
1806
+ async function askConfirmPlan({ convo, generate, mode, book, label }) {
1807
+ const halt = book.capStop();
1808
+ if (halt) return { plan: null, convo, providerError: null, red: null, budget: halt };
1809
+ const r = await askStructured({ messages: convo, generate, mode, retries: MAX_STRUCTURE_RETRIES, label, book, channel: confirmChannel() });
1810
+ return { plan: r.artifact, convo: r.convo, providerError: r.providerError, red: r.red, budget: r.budget };
1811
+ }
1812
+
1813
+ /**
1814
+ * THE CONFIRM TURN: the $0 half (worse-than-before for a repo, a language
1815
+ * pick when `lang` carries `detectLanguage`'s own `ambiguous` shape), then up
1816
+ * to 2 PAID rounds (ruling 5) of draft → show → the person's menu pick
1817
+ * (ruling 5 addendum). `ask` is the ONE interactive seam — an async function
1818
+ * from a "what's being asked" descriptor to the person's answer, or `null`
1819
+ * meaning input ended (the same signal `scripts/run-interview.mjs`'s own
1820
+ * `nextLine()` already uses) — never a process exit here, because this is a
1821
+ * library function, not a script. A `null` at ANY ask stops the whole turn as
1822
+ * `confirm-abandoned`, spending nothing beyond whatever rounds already ran.
1823
+ *
1824
+ * D3's shape is enforced by the loop bound alone (`round <= 2`), never a
1825
+ * counter checked after the fact: a "fix" on round 2 returns immediately —
1826
+ * there is no 3rd call to make.
1827
+ *
1828
+ * @param {{verdictType: string, answers: Record<string|number, string>,
1829
+ * questions: Record<string|number, string>, labels?: Record<string|number, string>,
1830
+ * facts?: any, listing?: string|null, writeScope?: string[]|null, isRepo: boolean,
1831
+ * lang: string|{kind: 'ambiguous', candidates: string[], dir: string},
1832
+ * generate: Function, book: ReturnType<typeof makeCostBook>,
1833
+ * ask: (step: {kind: string, [k: string]: any}) => Promise<string|null>,
1834
+ * onPhase?: (phase: string, data?: any) => void, mode?: 'tool'|'text',
1835
+ * worseThanBefore?: string}} o
1836
+ * @returns {Promise<{ok: boolean,
1837
+ * stop: null|'cap-halt'|'pricing-red'|'provider-red'|'artifact-red'|'confirm-abandoned'|'confirm-restart',
1838
+ * rounds: number,
1839
+ * accepted: {goal: string, checks: string[], protections: string[], lang: string,
1840
+ * worseThanBefore: string, openQuestions: string[], notChecked: string[], answeredQuestions?: string[]}|null,
1841
+ * reds: Red[], cost: any}>}
1842
+ */
1843
+ export async function runConfirmTurn({
1844
+ verdictType, answers, questions, labels = {}, facts = null, listing = null, writeScope = null,
1845
+ isRepo, lang, generate, book, ask, onPhase = () => {}, mode = 'tool',
1846
+ // `undefined` (the default) means "ask me" — every S2 caller (nothing
1847
+ // pre-resolves this). A caller that already asked worseThanBefore itself
1848
+ // (`authorCloseForJob`'s own $0 phase, run BEFORE the scout per D7) passes
1849
+ // the resolved string straight through, and this turn asks nothing a
1850
+ // second time. `''` is a legal resolved answer ("nothing beyond
1851
+ // Guardrails") and is NOT the same as "ask me" — only `undefined` is.
1852
+ worseThanBefore: presetWorseThanBefore = undefined,
1853
+ }) {
1854
+ /** @typedef {null|'cap-halt'|'pricing-red'|'provider-red'|'artifact-red'|'confirm-abandoned'|'confirm-restart'} ConfirmStop */
1855
+ /** @returns {{ok: boolean, stop: ConfirmStop, rounds: number, accepted: null, reds: Red[], cost: any}} */
1856
+ const base = () => ({ ok: false, stop: null, rounds: 0, accepted: null, reds: [], cost: book.report() });
1857
+ /** @param {number} rounds @returns {{ok: boolean, stop: ConfirmStop, rounds: number, accepted: null, reds: Red[], cost: any}} */
1858
+ const abandon = (rounds) => ({ ...base(), stop: /** @type {ConfirmStop} */ ('confirm-abandoned'), rounds, cost: book.report() });
1859
+
1860
+ // ── the $0 half, entirely before any token spends (D7) ────────────────────
1861
+ /** @type {string} */
1862
+ let worseThanBefore = '';
1863
+ if (presetWorseThanBefore !== undefined) {
1864
+ worseThanBefore = presetWorseThanBefore;
1865
+ } else if (isRepo) {
1866
+ onPhase('confirm-worse-than-before');
1867
+ const wtb = await ask({ kind: 'worseThanBefore', field: WORSE_THAN_BEFORE_FIELD });
1868
+ if (wtb === null) return abandon(0);
1869
+ worseThanBefore = redactSecrets(String(wtb).trim());
1870
+ }
1871
+
1872
+ /** @type {string} */
1873
+ let resolvedLang = typeof lang === 'string' ? lang : '';
1874
+ if (lang && typeof lang === 'object' && lang.kind === 'ambiguous') {
1875
+ onPhase('confirm-language-pick', { candidates: lang.candidates });
1876
+ const pick = await ask({ kind: 'language', field: LANGUAGE_PICK_FIELD, candidates: lang.candidates });
1877
+ if (pick === null) return abandon(0);
1878
+ resolvedLang = String(pick);
1879
+ }
1880
+
1881
+ // F183 (2026-09-16, ruling A): computed ONCE, before the model ever drafts,
1882
+ // from the same source {@link confirmProtections} always used — fed into
1883
+ // the prompt below so the model can check `notChecked` against it, and
1884
+ // reused (never recomputed) after the call so the shown, prompted, and
1885
+ // recorded protections are always the exact same list.
1886
+ const protections = confirmProtections({ verdictType, lang: resolvedLang, writeScope });
1887
+
1888
+ // ── up to 2 paid rounds (ruling 5) ─────────────────────────────────────────
1889
+ /** @type {string|null} */
1890
+ let fixText = null;
1891
+ /** F175: `accepted.openQuestions` carries the model's OWN `questions` from
1892
+ * the plan being ACCEPTED — never a prior, superseded round's — secret-
1893
+ * redacted like every other model/person string on this turn. `fixText`
1894
+ * (round 1's own "fix" text) is a SEPARATE mechanism that only feeds round
1895
+ * 2's redraft; it must never land in `openQuestions` on its own — round 2's
1896
+ * plan is the answer to it, and its own `questions` (if it still has any)
1897
+ * are what actually carries forward.
1898
+ * @param {any} plan @returns {string[]} */
1899
+ const questionsFromPlan = (plan) => (Array.isArray(plan?.questions) ? plan.questions : [])
1900
+ .map((/** @type {any} */ q) => redactSecrets(String(q)));
1901
+ /**
1902
+ * F175's open half (run mu0voeo4, hamr's 2026-09-16 ruling): "Confirm" and
1903
+ * "Type the goal yourself" may not bypass a `questions` entry the plan
1904
+ * being accepted raised — each is answered inline, in order, through the
1905
+ * SAME `ask` seam, with a new `kind: 'answer'` step (NO extra model call:
1906
+ * the round/D3 cap is untouched). A blank/whitespace answer is not an
1907
+ * answer and re-asks the SAME question; a `null` from `ask` abandons,
1908
+ * exactly like every other ask in this function. Returns the `Q:/A:`
1909
+ * pairs in order, or `null` to mean abandon.
1910
+ * @param {any} plan @returns {Promise<string[]|null>} */
1911
+ const forceAnsweredQuestions = async (plan) => {
1912
+ const qs = questionsFromPlan(plan);
1913
+ /** @type {string[]} */
1914
+ const answered = [];
1915
+ for (let i = 0; i < qs.length; i += 1) {
1916
+ const question = qs[i];
1917
+ for (;;) {
1918
+ const raw = await ask({ kind: 'answer', field: ANSWER_QUESTION_FIELD, question, index: i + 1, total: qs.length });
1919
+ if (raw === null) return null;
1920
+ const trimmed = redactSecrets(String(raw).trim());
1921
+ if (trimmed === '') continue; // blank is not an answer — re-ask the same question
1922
+ answered.push(`Q: ${question}\nA: ${trimmed}`);
1923
+ break;
1924
+ }
1925
+ }
1926
+ return answered;
1927
+ };
1928
+ let convo = [{
1929
+ role: 'user',
1930
+ content: confirmPrompt({
1931
+ answers, questions, labels, facts, listing, writeScope, isRepo, lang: resolvedLang, worseThanBefore, protections,
1932
+ }),
1933
+ }];
1934
+
1935
+ for (let round = 1; round <= 2; round += 1) {
1936
+ if (fixText) {
1937
+ convo = [...convo, { role: 'user', content: confirmPrompt({
1938
+ answers, questions, labels, facts, listing, writeScope, isRepo, lang: resolvedLang, worseThanBefore, fixText, protections,
1939
+ }) }];
1940
+ }
1941
+ onPhase('confirm-round', { round });
1942
+ const label = round === 1 ? 'confirm' : `confirm#${round}`;
1943
+ const r = await askConfirmPlan({ convo, generate, mode, book, label });
1944
+ convo = r.convo;
1945
+
1946
+ if (r.budget) return { ...base(), stop: r.budget, rounds: round - 1, cost: book.report() };
1947
+ 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() };
1948
+ if (!r.plan) return { ...base(), stop: /** @type {ConfirmStop} */ ('artifact-red'), rounds: round, reds: [/** @type {Red} */ (r.red)], cost: book.report() };
1949
+
1950
+ // "Unpriced is never free" (F6): a plan can arrive structurally fine on a
1951
+ // call whose OWN cost came back unknown. Nothing downstream ever makes
1952
+ // another call to discover that (a "confirm" pick is the last call this
1953
+ // turn ever makes), so it is checked HERE, once, right after the call
1954
+ // that might have caused it — never silently accepted as a signable plan
1955
+ // whose ceiling can no longer be enforced.
1956
+ const postCallHalt = book.capStop();
1957
+ if (postCallHalt) return { ...base(), stop: postCallHalt, rounds: round, cost: book.report() };
1958
+
1959
+ // THE REAL PROTECTIONS (fix #1, run mu0voeo4, 2026-09-14): never the
1960
+ // model's `protections` prose — the schema no longer even asks the model
1961
+ // for one — but the guards `classGuards` will actually compose, plus the
1962
+ // write fence. Computed once above (F183) and reused here unchanged, so
1963
+ // the list the model was prompted with is byte-identical to the list
1964
+ // shown at confirm-done and recorded on acceptance. `notChecked` is the
1965
+ // model's own honest gap list, carried through unchanged.
1966
+ const notChecked = [...(r.plan.notChecked ?? [])];
1967
+ const plan = { ...r.plan, protections, notChecked };
1968
+ onPhase('confirm-done', { round, plan });
1969
+ const picked = await ask({ kind: 'menu', field: CONFIRM_MENU, plan });
1970
+ if (picked === null) return abandon(round);
1971
+ if (picked === 'start-over') return { ...base(), stop: /** @type {ConfirmStop} */ ('confirm-restart'), rounds: round, cost: book.report() };
1972
+
1973
+ if (picked === 'confirm') {
1974
+ const answeredQuestions = await forceAnsweredQuestions(r.plan);
1975
+ if (answeredQuestions === null) return abandon(round);
1976
+ return {
1977
+ ok: true, stop: null, rounds: round,
1978
+ accepted: {
1979
+ goal: String(r.plan.goal ?? ''), checks: [...(r.plan.checks ?? [])], protections: [...protections],
1980
+ lang: resolvedLang, worseThanBefore,
1981
+ // every question forced above was answered in order, so what's left
1982
+ // unresolved is honestly empty — computed FROM questionsFromPlan
1983
+ // rather than a hardcoded `[]`, so this stays true if the forcing
1984
+ // design ever changes.
1985
+ openQuestions: questionsFromPlan(r.plan).slice(answeredQuestions.length),
1986
+ notChecked: [...notChecked], answeredQuestions,
1987
+ },
1988
+ reds: [], cost: book.report(),
1989
+ };
1990
+ }
1991
+
1992
+ if (picked === 'type-goal') {
1993
+ const answeredQuestions = await forceAnsweredQuestions(r.plan);
1994
+ if (answeredQuestions === null) return abandon(round);
1995
+ const typed = await ask({ kind: 'goal' });
1996
+ if (typed === null) return abandon(round);
1997
+ return {
1998
+ ok: true, stop: null, rounds: round,
1999
+ accepted: {
2000
+ goal: redactSecrets(String(typed).trim()), checks: [...(r.plan.checks ?? [])], protections: [...protections],
2001
+ lang: resolvedLang, worseThanBefore,
2002
+ openQuestions: questionsFromPlan(r.plan).slice(answeredQuestions.length),
2003
+ notChecked: [...notChecked], answeredQuestions,
2004
+ },
2005
+ reds: [], cost: book.report(),
2006
+ };
2007
+ }
2008
+
2009
+ // picked === 'fix'
2010
+ const fix = await ask({ kind: 'fix' });
2011
+ if (fix === null) return abandon(round);
2012
+ const redactedFix = redactSecrets(String(fix).trim());
2013
+ if (round === 2) {
2014
+ // D3: after round 2 a "fix" is passed to the composer verbatim (via
2015
+ // openQuestions, shown at signing) — there is no 3rd call. F175: the
2016
+ // MODEL's own questions from THIS round's plan come first, then the
2017
+ // person's still-pending fix text — order matters (model asked first,
2018
+ // person spoke last) and neither is dropped.
2019
+ return {
2020
+ ok: true, stop: null, rounds: round,
2021
+ accepted: {
2022
+ goal: String(r.plan.goal ?? ''), checks: [...(r.plan.checks ?? [])], protections: [...protections],
2023
+ lang: resolvedLang, worseThanBefore, openQuestions: [...questionsFromPlan(r.plan), redactedFix], notChecked: [...notChecked],
2024
+ },
2025
+ reds: [], cost: book.report(),
2026
+ };
2027
+ }
2028
+ fixText = redactedFix;
2029
+ }
2030
+ // unreachable: the loop above always returns by round 2 — kept only so a
2031
+ // future edit that breaks that invariant fails loudly rather than falling
2032
+ // through to `undefined`.
2033
+ throw new Error('[authorflow] runConfirmTurn fell through its 2-round cap without returning');
2034
+ }
2035
+
1276
2036
  /**
1277
2037
  * THE AUTHORING FLOW.
1278
2038
  *
@@ -1284,7 +2044,13 @@ async function askDeclaration({ messages, generate, mode, retries, label, book,
1284
2044
  * The return is deliberately complete rather than tidy:
1285
2045
  * - `ok` is true iff a declaration passed validation and was measured;
1286
2046
  * - `declaration` is the LAST ACCEPTED one, genre-env injected — the exact form
1287
- * that will run and that the signature will cover;
2047
+ * that will run and that the signature will cover; unless F176's fallback
2048
+ * fired (see `fellBack`), in which case it is the newest SOUND accepted one
2049
+ * instead — a "last accepted" close whose seed read instrument-stopped is
2050
+ * never handed to a signer as though it were the best close this run found;
2051
+ * - `fellBack` is `null` unless the fallback above fired, in which case it names
2052
+ * `{from, to, brokenStages}` — which iteration was dropped, which one now
2053
+ * stands, and which of the dropped one's stages instrument-stopped;
1288
2054
  * - `reds` is what the LAST validated iteration said. `ok:true` with a non-empty
1289
2055
  * `reds` means the final revision was rejected and the previous close stands:
1290
2056
  * the rejection is reported, never hidden, and never silently shipped either;
@@ -1309,12 +2075,32 @@ async function askDeclaration({ messages, generate, mode, retries, label, book,
1309
2075
  * onPhase?: (phase: string, data?: any) => void,
1310
2076
  * onCall?: (call: {label: string, costUsd: number|null, unpricedRounds: number}) => void,
1311
2077
  * maxRevisions?: number, structureRetries?: number,
1312
- * structuredMode?: 'tool'|'text', catalogue?: Record<string, any>}} o
2078
+ * structuredMode?: 'tool'|'text', catalogue?: Record<string, any>, writeScope?: string[]|null,
2079
+ * priorCalls?: {label: string, costUsd: number|null, unpricedRounds: number}[]|null,
2080
+ * priorRaws?: any[]|null,
2081
+ * confirmed?: {checks: string[], protections: string[], openQuestions?: string[], notChecked?: string[], answeredQuestions?: string[]}|null}} o
1313
2082
  */
1314
2083
  export async function authorClose({
1315
2084
  workdir, seedRef, lang, verdictType,
1316
2085
  answers, questions = GREEN_QUESTIONS,
2086
+ // Q2 IS GONE from the numbered interview (PRD item 33 M3 piece 3) — this is
2087
+ // what carries its information into the prompt instead (`writeScopeBlock`).
2088
+ // Optional and defaulted to `null` so every caller that predates the reshape
2089
+ // still runs byte-identical; a REAL caller (`authorCloseForJob`) always
2090
+ // passes the repo's proven `writeScope`.
2091
+ writeScope = null,
1317
2092
  scout, listing = null,
2093
+ // PRD item 33 M3 piece 4: the confirm turn's own paid calls — absorbed
2094
+ // BESIDE the scout's, below, so the ONE ceiling this run advertised is the
2095
+ // one it enforces (the standing hard line's money form): a confirm turn
2096
+ // that already spent half the ceiling must not let the author call spend
2097
+ // as if it never happened. `null`/absent is every caller that ran no
2098
+ // confirm turn — byte-identical to today.
2099
+ priorCalls = null, priorRaws = null,
2100
+ // The confirm turn's ACCEPTED plan (`runConfirmTurn`'s `accepted`), fed
2101
+ // straight into `authorPrompt`'s `confirmedBlock`. `null` is every caller
2102
+ // that ran no confirm turn — the prompt is then byte-identical to today.
2103
+ confirmed = null,
1318
2104
  generate, seedReadFn = runSeedReadStages, closeCtx = {},
1319
2105
  ceilingUsd = null,
1320
2106
  // The two REPORTING seams, defaulted to nothing so every existing caller is
@@ -1351,6 +2137,7 @@ export async function authorClose({
1351
2137
  // that spends nothing more: run mslhn707 refused at the $0 preflight below,
1352
2138
  // and the survey text that would have said WHY died with the process.
1353
2139
  book.absorb(scout?.calls ?? [], scout?.raws ?? []);
2140
+ book.absorb(priorCalls ?? [], priorRaws ?? []);
1354
2141
  /** @type {any} */
1355
2142
  const base = {
1356
2143
  ok: false, declaration: null, seedRead: null, iterations: [], reds: [], cost: book.report(),
@@ -1487,7 +2274,7 @@ export async function authorClose({
1487
2274
  // ── the grounded loop ─────────────────────────────────────────────────────
1488
2275
  const prompt = authorPrompt({
1489
2276
  answers, questions, facts, listingBlock: /** @type {string} */ (seeds.block),
1490
- lang, verdictType, guards, ownedEnvNames, mode: structuredMode, catalogue,
2277
+ lang, verdictType, guards, ownedEnvNames, mode: structuredMode, catalogue, writeScope, confirmed,
1491
2278
  });
1492
2279
  /** @type {any[]} */
1493
2280
  let messages = [{ role: 'user', content: prompt }];
@@ -1508,6 +2295,19 @@ export async function authorClose({
1508
2295
  let stop = 'max-revisions';
1509
2296
  /** @type {{stage: string, name: string}[]} */
1510
2297
  let droppedEnv = [];
2298
+ // F176: every MEASURED iteration (validated + seed-read, never a
2299
+ // validation-rejected one), kept in call order, so a later revision that
2300
+ // broke what an earlier one got right can be detected and reverted rather
2301
+ // than silently kept because it happened to be last. Each entry carries its
2302
+ // OWN droppedEnv — the single `droppedEnv` variable above is overwritten by
2303
+ // every measured iteration and would mismatch a fallen-back iteration.
2304
+ /** @type {{label: string, declaration: any, seedRead: any[], droppedEnv: {stage: string, name: string}[]}[]} */
2305
+ const measuredIterations = [];
2306
+ /** a measured iteration is SOUND when none of its seed-read rows instrument-stopped
2307
+ * (hamr's ruling, 2026-09-14, option A) — a real red/green verdict is sound, a
2308
+ * broken instrument is not.
2309
+ * @param {any[]} rows @returns {boolean} */
2310
+ const isSoundMeasurement = (rows) => Array.isArray(rows) && rows.every((r) => r?.verdict !== 'instrument-stop');
1511
2311
 
1512
2312
  /**
1513
2313
  * THE GOVERNANCE STOP'S OWN RED. It names the CAP and the SPEND, because the
@@ -1654,6 +2454,7 @@ export async function authorClose({
1654
2454
  acceptedSeedRead = rows;
1655
2455
  finalFrom = label;
1656
2456
  measured = renderSeedReadBlock(rows);
2457
+ measuredIterations.push({ label, declaration: injected.declaration, seedRead: rows, droppedEnv: injected.dropped });
1657
2458
  }
1658
2459
 
1659
2460
  if (i === revisionCap) { stop = 'max-revisions'; break; }
@@ -1674,6 +2475,43 @@ export async function authorClose({
1674
2475
  await seedTrees.cleanup();
1675
2476
  }
1676
2477
 
2478
+ // F176 (hamr's ruling, 2026-09-14, option A): "kept the last accepted
2479
+ // revision" is not the same claim as "kept the best revision" — a revise
2480
+ // loop that only checks validity per round, never soundness ACROSS rounds,
2481
+ // can silently regress on its own last try. If the last MEASURED iteration
2482
+ // is not sound (an instrument-stop, not a real red/green verdict) and an
2483
+ // earlier measured iteration IS sound, fall back to the NEWEST sound one:
2484
+ // its declaration, its seedRead, its own finalFrom label, and its own
2485
+ // droppedEnv — never the module-level `droppedEnv`, which the unsound last
2486
+ // iteration already overwrote. `stop` itself is never changed: whatever
2487
+ // stopped the ladder still stopped it for the reason it stopped it, this
2488
+ // only changes WHICH close is reported as the one that stands. Said loudly,
2489
+ // never silently: `fellBack` names it and `onPhase` reports it as it
2490
+ // happens, so a caller emitting phases to a spine or a terminal cannot miss
2491
+ // it by reading only `declaration`/`seedRead`/`finalFrom`.
2492
+ /** @type {{from: string, to: string, brokenStages: string[]}|null} */
2493
+ let fellBack = null;
2494
+ if (measuredIterations.length) {
2495
+ const last = measuredIterations[measuredIterations.length - 1];
2496
+ if (!isSoundMeasurement(last.seedRead)) {
2497
+ for (let k = measuredIterations.length - 2; k >= 0; k -= 1) {
2498
+ const candidate = measuredIterations[k];
2499
+ if (isSoundMeasurement(candidate.seedRead)) {
2500
+ const brokenStages = last.seedRead
2501
+ .filter((/** @type {any} */ r) => r?.verdict === 'instrument-stop')
2502
+ .map((/** @type {any} */ r) => r.stage);
2503
+ fellBack = { from: last.label, to: candidate.label, brokenStages };
2504
+ accepted = candidate.declaration;
2505
+ acceptedSeedRead = candidate.seedRead;
2506
+ finalFrom = candidate.label;
2507
+ droppedEnv = candidate.droppedEnv;
2508
+ onPhase('author-fallback', fellBack);
2509
+ break;
2510
+ }
2511
+ }
2512
+ }
2513
+ }
2514
+
1677
2515
  const reds = fatal.length ? fatal : (lastValidation && !lastValidation.ok ? lastValidation.reds : []);
1678
2516
  return {
1679
2517
  // `ok` means A SIGNABLE CLOSE EXISTS, and nothing more. A dead socket on a
@@ -1697,6 +2535,11 @@ export async function authorClose({
1697
2535
  listing: seeds,
1698
2536
  genreEnv: { ...base.genreEnv, dropped: droppedEnv },
1699
2537
  finalFrom,
2538
+ // F176: null on every path where the last measured iteration was itself
2539
+ // sound, or where no measured iteration exists — byte-identical to today.
2540
+ // Non-null names WHICH iteration was dropped, WHICH one now stands, and
2541
+ // WHICH stages broke on the dropped one, so this is never a silent swap.
2542
+ fellBack,
1700
2543
  stop,
1701
2544
  revisions: Math.max(0, iterations.length - 1),
1702
2545
  };