bareloop 0.26.0 → 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);
@@ -469,6 +469,24 @@ export const LANGUAGE_PICK_FIELD = Object.freeze({
469
469
  prompt: 'This repo has more than one supported language\'s manifest at the same level. Which one is this job about?',
470
470
  });
471
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
+
472
490
  /**
473
491
  * THE CONFIRM TURN'S MENU — a structured CHOICE the person picks from after
474
492
  * each round, never free text matched against a pattern (ruling 6's "no
@@ -975,13 +993,21 @@ export function writeScopeBlock(writeScope) {
975
993
  * no listed check can verify — shown so the composer names the gap in its
976
994
  * own notes rather than the plan quietly reading as if everything asked for
977
995
  * is covered.
978
- * @param {{checks: string[], protections: string[], openQuestions?: string[], notChecked?: string[]}} confirmed
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
979
1004
  */
980
1005
  export function confirmedBlock(confirmed) {
981
1006
  const checks = confirmed.checks ?? [];
982
1007
  const protections = confirmed.protections ?? [];
983
1008
  const openQuestions = confirmed.openQuestions ?? [];
984
1009
  const notChecked = confirmed.notChecked ?? [];
1010
+ const answeredQuestions = confirmed.answeredQuestions ?? [];
985
1011
  return 'THE CONFIRMED PLAN — the person already saw and confirmed this in the confirm turn\n\n'
986
1012
  + 'Compose stages for these checks and no other — a genre never adds a check the goal did not ask for, and '
987
1013
  + 'neither do you:\n\n'
@@ -992,6 +1018,9 @@ export function confirmedBlock(confirmed) {
992
1018
  ? 'THE PERSON ASKED FOR THESE, BUT NOTHING CHECKS THEM (state these in your notes — never silently claim they '
993
1019
  + `are covered):\n\n${notChecked.map((n) => ` - ${n}`).join('\n')}\n\n`
994
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
+ : '')
995
1024
  + (openQuestions.length
996
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')}`
997
1026
  : 'The confirm turn left no open questions.');
@@ -1019,7 +1048,7 @@ export function confirmedBlock(confirmed) {
1019
1048
  * guards: {name: string, kind: string, params: Record<string, any>, fill: string[]}[],
1020
1049
  * ownedEnvNames?: string[], mode?: 'tool'|'text', catalogue?: Record<string, any>,
1021
1050
  * writeScope?: string[]|null,
1022
- * confirmed?: {checks: string[], protections: string[], openQuestions?: string[], notChecked?: string[]}|null}} o
1051
+ * confirmed?: {checks: string[], protections: string[], openQuestions?: string[], notChecked?: string[], answeredQuestions?: string[]}|null}} o
1023
1052
  */
1024
1053
  export function authorPrompt({
1025
1054
  answers, questions = GREEN_QUESTIONS, facts, listingBlock, lang, verdictType, guards,
@@ -1438,6 +1467,10 @@ export function makeLoopGenerate(provider, { system = AUTHOR_SYSTEM, maxTokens =
1438
1467
  // answers hangs the process forever otherwise. `...opts` still spreads AFTER
1439
1468
  // it, so a caller that ever needs a tighter number for one call may pass it —
1440
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.
1441
1474
  return loop.run(messages, wired, { cacheMessages: true, maxTokens, timeoutMs: AUTHOR_CALL_TIMEOUT_MS, ...opts });
1442
1475
  };
1443
1476
  }
@@ -1493,7 +1526,28 @@ export async function askStructured({ messages, generate, mode, retries, label,
1493
1526
  // two must offer one set of kinds, or the composer reads about a kind it
1494
1527
  // cannot call — or worse, calls one the ceiling will refuse after we paid.
1495
1528
  const tools = mode === 'tool' ? [channel.tool(box)] : [];
1496
- 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
+ }
1497
1551
  attempts += 1;
1498
1552
  book.add(attempt === 0 ? label : `${label}#${attempt + 1}`, r, attempts);
1499
1553
  const raw = redactSecrets(String(r?.text ?? ''));
@@ -1503,15 +1557,43 @@ export async function askStructured({ messages, generate, mode, retries, label,
1503
1557
  }
1504
1558
 
1505
1559
  if (mode === 'tool') {
1506
- if (box.calls.length === 1) return { artifact: box.calls[0], attempts, convo, raw, providerError: null, red: null, budget: null };
1507
- red = {
1508
- code: 'artifact-red',
1509
- path: channel.name,
1510
- detail: box.calls.length === 0
1511
- ? `the reply delivered no ${channel.name} call — structure is enforced, prose is never parsed`
1512
- : `the reply delivered ${box.calls.length} ${channel.name} calls; exactly one is expected`,
1513
- axis: box.calls.length === 0 ? 'no-declaration-tool-call' : 'multiple-declaration-tool-calls',
1514
- };
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
+ }
1515
1597
  } else {
1516
1598
  // THE FALLBACK PATH — providers without tool mode only. The ONE shipped
1517
1599
  // parser, never a second one.
@@ -1659,9 +1741,13 @@ export const CONFIRM_SYSTEM = 'You read what a NON-ENGINEER answered, plus a rea
1659
1741
  + 'a protection is never a check and never named in the goal sentence. The goal sentence names every check you '
1660
1742
  + 'listed and nothing more. For anything the person asked for that none of your listed checks can verify, name it '
1661
1743
  + '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.';
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.';
1665
1751
 
1666
1752
  /**
1667
1753
  * The confirm turn's per-round prompt (model-facing, registered). Shows the
@@ -1670,12 +1756,20 @@ export const CONFIRM_SYSTEM = 'You read what a NON-ENGINEER answered, plus a rea
1670
1756
  * listing already paid for, the write fence, the detected language, "worse
1671
1757
  * than before" when the person gave one (repo only), and — on a fix round —
1672
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.
1673
1767
  * @param {{answers: Record<string|number, string>, questions: Record<string|number, string>,
1674
1768
  * labels?: Record<string|number, string>, facts?: any, listing?: string|null,
1675
1769
  * writeScope?: string[]|null, isRepo: boolean, lang: string, worseThanBefore?: string,
1676
- * fixText?: string|null}} o
1770
+ * fixText?: string|null, protections?: string[]}} o
1677
1771
  */
1678
- export function confirmPrompt({ answers, questions, labels = {}, facts = null, listing = null, writeScope = null, isRepo, lang, worseThanBefore = '', fixText = null }) {
1772
+ export function confirmPrompt({ answers, questions, labels = {}, facts = null, listing = null, writeScope = null, isRepo, lang, worseThanBefore = '', fixText = null, protections = [] }) {
1679
1773
  const lines = ['THE PERSON\'S OWN ANSWERS (a non-engineer; read exactly what they wrote, invent nothing beyond it):'];
1680
1774
  for (const k of Object.keys(questions)) {
1681
1775
  const label = labels[k] ? ` (${labels[k]})` : '';
@@ -1687,6 +1781,15 @@ export function confirmPrompt({ answers, questions, labels = {}, facts = null, l
1687
1781
  }
1688
1782
  lines.push('', `LANGUAGE: ${lang}`);
1689
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
+ }
1690
1793
  if (facts) lines.push('', `READ-ONLY SURVEY:\n${JSON.stringify(facts)}`);
1691
1794
  if (listing) lines.push('', `SEED LISTING:\n${listing}`);
1692
1795
  if (fixText) lines.push('', `THE PERSON ASKED FOR A CHANGE: ${fixText}`, 'Revise the plan and resubmit.');
@@ -1734,7 +1837,7 @@ async function askConfirmPlan({ convo, generate, mode, book, label }) {
1734
1837
  * stop: null|'cap-halt'|'pricing-red'|'provider-red'|'artifact-red'|'confirm-abandoned'|'confirm-restart',
1735
1838
  * rounds: number,
1736
1839
  * accepted: {goal: string, checks: string[], protections: string[], lang: string,
1737
- * worseThanBefore: string, openQuestions: string[], notChecked: string[]}|null,
1840
+ * worseThanBefore: string, openQuestions: string[], notChecked: string[], answeredQuestions?: string[]}|null,
1738
1841
  * reds: Red[], cost: any}>}
1739
1842
  */
1740
1843
  export async function runConfirmTurn({
@@ -1775,22 +1878,64 @@ export async function runConfirmTurn({
1775
1878
  resolvedLang = String(pick);
1776
1879
  }
1777
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
+
1778
1888
  // ── up to 2 paid rounds (ruling 5) ─────────────────────────────────────────
1779
- /** @type {string[]} */
1780
- const openQuestions = [];
1781
1889
  /** @type {string|null} */
1782
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
+ };
1783
1928
  let convo = [{
1784
1929
  role: 'user',
1785
1930
  content: confirmPrompt({
1786
- answers, questions, labels, facts, listing, writeScope, isRepo, lang: resolvedLang, worseThanBefore,
1931
+ answers, questions, labels, facts, listing, writeScope, isRepo, lang: resolvedLang, worseThanBefore, protections,
1787
1932
  }),
1788
1933
  }];
1789
1934
 
1790
1935
  for (let round = 1; round <= 2; round += 1) {
1791
1936
  if (fixText) {
1792
1937
  convo = [...convo, { role: 'user', content: confirmPrompt({
1793
- answers, questions, labels, facts, listing, writeScope, isRepo, lang: resolvedLang, worseThanBefore, fixText,
1938
+ answers, questions, labels, facts, listing, writeScope, isRepo, lang: resolvedLang, worseThanBefore, fixText, protections,
1794
1939
  }) }];
1795
1940
  }
1796
1941
  onPhase('confirm-round', { round });
@@ -1814,9 +1959,10 @@ export async function runConfirmTurn({
1814
1959
  // THE REAL PROTECTIONS (fix #1, run mu0voeo4, 2026-09-14): never the
1815
1960
  // model's `protections` prose — the schema no longer even asks the model
1816
1961
  // 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 });
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.
1820
1966
  const notChecked = [...(r.plan.notChecked ?? [])];
1821
1967
  const plan = { ...r.plan, protections, notChecked };
1822
1968
  onPhase('confirm-done', { round, plan });
@@ -1825,24 +1971,36 @@ export async function runConfirmTurn({
1825
1971
  if (picked === 'start-over') return { ...base(), stop: /** @type {ConfirmStop} */ ('confirm-restart'), rounds: round, cost: book.report() };
1826
1972
 
1827
1973
  if (picked === 'confirm') {
1974
+ const answeredQuestions = await forceAnsweredQuestions(r.plan);
1975
+ if (answeredQuestions === null) return abandon(round);
1828
1976
  return {
1829
1977
  ok: true, stop: null, rounds: round,
1830
1978
  accepted: {
1831
1979
  goal: String(r.plan.goal ?? ''), checks: [...(r.plan.checks ?? [])], protections: [...protections],
1832
- lang: resolvedLang, worseThanBefore, openQuestions: [...openQuestions], notChecked: [...notChecked],
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,
1833
1987
  },
1834
1988
  reds: [], cost: book.report(),
1835
1989
  };
1836
1990
  }
1837
1991
 
1838
1992
  if (picked === 'type-goal') {
1993
+ const answeredQuestions = await forceAnsweredQuestions(r.plan);
1994
+ if (answeredQuestions === null) return abandon(round);
1839
1995
  const typed = await ask({ kind: 'goal' });
1840
1996
  if (typed === null) return abandon(round);
1841
1997
  return {
1842
1998
  ok: true, stop: null, rounds: round,
1843
1999
  accepted: {
1844
2000
  goal: redactSecrets(String(typed).trim()), checks: [...(r.plan.checks ?? [])], protections: [...protections],
1845
- lang: resolvedLang, worseThanBefore, openQuestions: [...openQuestions], notChecked: [...notChecked],
2001
+ lang: resolvedLang, worseThanBefore,
2002
+ openQuestions: questionsFromPlan(r.plan).slice(answeredQuestions.length),
2003
+ notChecked: [...notChecked], answeredQuestions,
1846
2004
  },
1847
2005
  reds: [], cost: book.report(),
1848
2006
  };
@@ -1852,15 +2010,17 @@ export async function runConfirmTurn({
1852
2010
  const fix = await ask({ kind: 'fix' });
1853
2011
  if (fix === null) return abandon(round);
1854
2012
  const redactedFix = redactSecrets(String(fix).trim());
1855
- openQuestions.push(redactedFix);
1856
2013
  if (round === 2) {
1857
2014
  // D3: after round 2 a "fix" is passed to the composer verbatim (via
1858
- // openQuestions, shown at signing) — there is no 3rd call.
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.
1859
2019
  return {
1860
2020
  ok: true, stop: null, rounds: round,
1861
2021
  accepted: {
1862
2022
  goal: String(r.plan.goal ?? ''), checks: [...(r.plan.checks ?? [])], protections: [...protections],
1863
- lang: resolvedLang, worseThanBefore, openQuestions: [...openQuestions], notChecked: [...notChecked],
2023
+ lang: resolvedLang, worseThanBefore, openQuestions: [...questionsFromPlan(r.plan), redactedFix], notChecked: [...notChecked],
1864
2024
  },
1865
2025
  reds: [], cost: book.report(),
1866
2026
  };
@@ -1884,7 +2044,13 @@ export async function runConfirmTurn({
1884
2044
  * The return is deliberately complete rather than tidy:
1885
2045
  * - `ok` is true iff a declaration passed validation and was measured;
1886
2046
  * - `declaration` is the LAST ACCEPTED one, genre-env injected — the exact form
1887
- * 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;
1888
2054
  * - `reds` is what the LAST validated iteration said. `ok:true` with a non-empty
1889
2055
  * `reds` means the final revision was rejected and the previous close stands:
1890
2056
  * the rejection is reported, never hidden, and never silently shipped either;
@@ -1912,7 +2078,7 @@ export async function runConfirmTurn({
1912
2078
  * structuredMode?: 'tool'|'text', catalogue?: Record<string, any>, writeScope?: string[]|null,
1913
2079
  * priorCalls?: {label: string, costUsd: number|null, unpricedRounds: number}[]|null,
1914
2080
  * priorRaws?: any[]|null,
1915
- * confirmed?: {checks: string[], protections: string[], openQuestions?: string[], notChecked?: string[]}|null}} o
2081
+ * confirmed?: {checks: string[], protections: string[], openQuestions?: string[], notChecked?: string[], answeredQuestions?: string[]}|null}} o
1916
2082
  */
1917
2083
  export async function authorClose({
1918
2084
  workdir, seedRef, lang, verdictType,
@@ -2129,6 +2295,19 @@ export async function authorClose({
2129
2295
  let stop = 'max-revisions';
2130
2296
  /** @type {{stage: string, name: string}[]} */
2131
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');
2132
2311
 
2133
2312
  /**
2134
2313
  * THE GOVERNANCE STOP'S OWN RED. It names the CAP and the SPEND, because the
@@ -2275,6 +2454,7 @@ export async function authorClose({
2275
2454
  acceptedSeedRead = rows;
2276
2455
  finalFrom = label;
2277
2456
  measured = renderSeedReadBlock(rows);
2457
+ measuredIterations.push({ label, declaration: injected.declaration, seedRead: rows, droppedEnv: injected.dropped });
2278
2458
  }
2279
2459
 
2280
2460
  if (i === revisionCap) { stop = 'max-revisions'; break; }
@@ -2295,6 +2475,43 @@ export async function authorClose({
2295
2475
  await seedTrees.cleanup();
2296
2476
  }
2297
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
+
2298
2515
  const reds = fatal.length ? fatal : (lastValidation && !lastValidation.ok ? lastValidation.reds : []);
2299
2516
  return {
2300
2517
  // `ok` means A SIGNABLE CLOSE EXISTS, and nothing more. A dead socket on a
@@ -2318,6 +2535,11 @@ export async function authorClose({
2318
2535
  listing: seeds,
2319
2536
  genreEnv: { ...base.genreEnv, dropped: droppedEnv },
2320
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,
2321
2543
  stop,
2322
2544
  revisions: Math.max(0, iterations.length - 1),
2323
2545
  };
package/src/authorjob.js CHANGED
@@ -866,18 +866,21 @@ export const AUTHORED_SPEC_FIELDS = Object.freeze(['close', 'closeDecl', 'verdic
866
866
  // hand-types the field name `'goal'` a second time.
867
867
  export const CONFIRM_AUTHORED_FIELDS = Object.freeze(['goal']);
868
868
 
869
- // PRD item 33 M3 piece 4, step S6 (D5 = A: a plain-folder source runs no
870
- // scout, and its confirm turn ends in the honest "no checks yet" stop, M4).
871
- // `writeScope` is the signed fence for a REPO job (Destination's own proven
872
- // answer IS it); for a plain folder, Destination is an OUTPUT directory, not
873
- // a fence, and this build authors no close for that kind of job at all — so
869
+ // PRD item 33 M3 piece 4, step S6 (D5 amended 2026-09-21, F191: a
870
+ // plain-folder source runs no scout AND no confirm turn — `run-author.mjs`
871
+ // gives the honest "no checks yet" stop immediately, at $0, rather than
872
+ // after a confirm turn that turned out to be unreachable for one). `writeScope`
873
+ // is the signed fence for a REPO job (Destination's own proven answer IS
874
+ // it); for a plain folder, Destination is an OUTPUT directory, not a fence,
875
+ // and this build authors no close for that kind of job at all — so
874
876
  // `scripts/run-interview.mjs` writes no `writeScope` field for one. Named
875
877
  // here, separately from `CONFIRM_AUTHORED_FIELDS`, because nothing in THIS
876
878
  // build ever fills it in for a plain-folder draft (goal, above, IS always
877
- // filled in later, by the confirm turn) — it stays absent until M4 gives a
878
- // plain-folder job a fence of its own kind. Filtering it unconditionally in
879
- // the $0 validator pass is harmless for a repo draft, which always carries
880
- // one: there is never a `writeScope` red left to filter there.
879
+ // filled in later, by the confirm turn — but only on a REPO job now) — it
880
+ // stays absent until M4 gives a plain-folder job a fence of its own kind.
881
+ // Filtering it unconditionally in the $0 validator pass is harmless for a
882
+ // repo draft, which always carries one: there is never a `writeScope` red
883
+ // left to filter there.
881
884
  export const PLAIN_FOLDER_DEFERRED_FIELDS = Object.freeze(['writeScope']);
882
885
 
883
886
  /**
@@ -54,7 +54,7 @@ import { seedListing, GATE_AUDIT_FILE, ARBITER_BOOK_STORES } from './kinds.js';
54
54
  import { TOOL_MENU, WRITE_VERBS, STORE_VERBS } from './job.js';
55
55
  import { TOOL_BY_VERB, CTX_TOOLS, createCtxTools, toolAction, strategyFor } from './tools.js';
56
56
  import { redactSecrets, SECRET_PATTERNS } from './validate.js';
57
- import { extractArtifact, priceOf, scrubRaw, stampRaw, tallyCalls, capStop } from './text.js';
57
+ import { extractArtifact, priceOf, scrubRaw, stampRaw, tallyCalls, capStop, callCasualty } from './text.js';
58
58
  import { PROVIDER_TIMEOUT_MS } from './clock.js';
59
59
 
60
60
  const require = createRequire(import.meta.url);
@@ -448,20 +448,25 @@ export async function runAuthorScout({
448
448
  * under the EXISTING `call-failed` cause. Without this seam a rejection would
449
449
  * escape uncaught to `run-author.mjs`'s top-level crash handler instead of the
450
450
  * honest, already-typed terminal this module was built to report through.
451
+ *
452
+ * The admitted-casualty test is `callCasualty` (`src/text.js`, beside
453
+ * `priceOf`) — the SAME predicate `askStructured` (`src/authorflow.js`, F179)
454
+ * uses for its own generate seam, widened past the idle-timeout class this
455
+ * comment originally named to also admit a malformed tool-call-arguments
456
+ * SyntaxError (F179's class): both are "a call that produced nothing", the
457
+ * definition `call-failed` already carries. Swallowing every throw would
458
+ * launder a budget HaltError, a programming bug, or any other named terminal
459
+ * into "the survey call failed" — the exact class of dishonesty this repo
460
+ * refuses (an unknown reported as a known) — so anything `callCasualty` does
461
+ * not admit still re-raises and is relayed after cleanup, as it was before
462
+ * this seam existed.
451
463
  * @param {Promise<any>} p @returns {Promise<any>}
452
464
  */
453
465
  const settled = async (p) => {
454
466
  try { return await p; } catch (e) {
455
- // ONLY the call's own deadline lands here. Swallowing every throw would
456
- // launder a budget HaltError, a programming bug, or any other named
457
- // terminal into "the survey call failed" — the exact class of dishonesty
458
- // this repo refuses (an unknown reported as a known). A timeout is the one
459
- // throw this seam exists for: it IS a call that produced nothing, which is
460
- // what `call-failed` already means. Everything else re-raises and is
461
- // relayed after cleanup, as it was before this seam existed.
462
- const err = /** @type {any} */ (e);
463
- if (err?.code !== 'ETIMEDOUT' && err?.name !== 'TimeoutError') throw e;
464
- return { error: String(err?.message ?? err) };
467
+ const reason = callCasualty(e);
468
+ if (reason === null) throw e;
469
+ return { error: reason };
465
470
  }
466
471
  };
467
472
 
package/src/cli.js CHANGED
@@ -33,7 +33,7 @@ import {
33
33
  exportBundle, readBundle, resolveBundleSpec, checkEnvelope, bless, verifyBlessing, appendHistory, checkBundleDeps,
34
34
  runJob, makeSpine, loadRegistry, listingRow, jobSpecHash, resolveWorkerModel, resolveJudge,
35
35
  } from './index.js';
36
- import { resolveProvider, buildRunnerProviders } from './providers.js';
36
+ import { resolveProvider, buildRunnerProviders, apiKeyProblem } from './providers.js';
37
37
 
38
38
  // The tier->model tables live in `src/providers.js` now (PRD item 28's
39
39
  // factory) — one seam instead of a copy hardcoded in each runner. A
@@ -275,6 +275,15 @@ async function doRun(args, { out, err, cwd, env, now, deps }) {
275
275
  out(`bundleHash: ${bundle.manifest.bundleHash}`);
276
276
  return 0;
277
277
  }
278
+ // F181 — a key that reads as "set" above can still carry a line break/
279
+ // control char/stray whitespace and crash Node's own header-encode
280
+ // inside the paid span. Refused here, at the same door, before any
281
+ // provider is constructed; never echoes the value.
282
+ const keyProblem = apiKeyProblem(apiKey);
283
+ if (keyProblem) {
284
+ err(`ANTHROPIC_API_KEY ${keyProblem} — refusing rather than crashing mid-call (never trimmed or repaired; fix the value at its source)`);
285
+ return 1;
286
+ }
278
287
  try {
279
288
  ({ provider, providerFor, judgeProvider, judgeModel } = buildProviders(apiKey, bundle.spec));
280
289
  } catch (e) {
package/src/index.js CHANGED
@@ -291,5 +291,5 @@ export { main as cliMain } from './cli.js';
291
291
  // module, not a re-derivation of it.
292
292
  export {
293
293
  prepareSource, proveDestination, copyOut, readSourceManifest, frontDoorFromManifest,
294
- datedDestination, pickDelivery,
294
+ datedDestination, pickDelivery, missingDependencies,
295
295
  } from './source.js';