@codedology/code-forge 0.3.2 → 0.4.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.
Files changed (66) hide show
  1. package/CHANGELOG.md +50 -0
  2. package/README.md +79 -21
  3. package/bin/code-forge.mjs +6 -1
  4. package/docs/README.md +5 -4
  5. package/docs/reference/blocks.json +153 -1
  6. package/docs/reference/config.md +8 -1
  7. package/package.json +4 -2
  8. package/schema/code-forge.schema.json +15 -4
  9. package/skill/SKILL.md +7 -7
  10. package/skill/references/adapters/claude-code.md +1 -1
  11. package/skill/references/adapters/solo.md +1 -1
  12. package/skill/references/adapters/subprocess.md +1 -1
  13. package/skill/references/code.md +4 -3
  14. package/skill/references/continuity.md +4 -0
  15. package/skill/references/decisions.md +5 -2
  16. package/skill/references/ledger.md +8 -2
  17. package/skill/references/plan.md +3 -3
  18. package/skill/references/review.md +8 -5
  19. package/skill/references/security.md +3 -1
  20. package/skill/scripts/forge +1 -1
  21. package/skill/templates/coder-brief.md +36 -0
  22. package/skill/templates/plan.md +2 -0
  23. package/src/cli/block.mjs +51 -8
  24. package/src/cli/jev.mjs +114 -17
  25. package/src/cli/ledger.mjs +99 -8
  26. package/src/cli/logs.mjs +193 -58
  27. package/src/cli/plan.mjs +27 -11
  28. package/src/cli/report.mjs +15 -1
  29. package/src/cli/resolve.mjs +16 -1
  30. package/src/cli/review-file.mjs +10 -5
  31. package/src/cli/run.mjs +109 -8
  32. package/src/config/closed-book.mjs +75 -0
  33. package/src/config/validate.mjs +128 -7
  34. package/src/decide/escalation.mjs +112 -11
  35. package/src/decide/questions.mjs +27 -4
  36. package/src/doctor/probes.mjs +48 -6
  37. package/src/engines/builders/claude.mjs +4 -1
  38. package/src/engines/builders/codex.mjs +26 -11
  39. package/src/engines/builders/grok.mjs +3 -3
  40. package/src/engines/efforts.mjs +109 -0
  41. package/src/engines/sentinel.mjs +34 -0
  42. package/src/keys/onepassword.mjs +3 -0
  43. package/src/ledger/report.mjs +66 -29
  44. package/src/ledger/spend.mjs +171 -0
  45. package/src/review/engine.mjs +57 -16
  46. package/src/review/fixloop.mjs +29 -6
  47. package/src/review/session-retry.mjs +69 -0
  48. package/src/review/triage.mjs +26 -1
  49. package/src/review/validate-review.mjs +6 -3
  50. package/src/session/author.mjs +6 -2
  51. package/src/session/plan-check.mjs +71 -6
  52. package/src/session/plan-sections.mjs +98 -0
  53. package/src/session/spawn.mjs +217 -8
  54. package/src/state/block.mjs +13 -4
  55. package/src/state/config-snapshot.mjs +150 -0
  56. package/src/state/run.mjs +86 -0
  57. package/src/util/breadcrumbs.mjs +145 -0
  58. package/src/util/error-hint.mjs +3 -2
  59. package/src/util/error-kind.mjs +2 -2
  60. package/src/util/error-log.mjs +109 -18
  61. package/src/util/forbidden.mjs +3 -1
  62. package/src/util/known-fixes.json +1 -0
  63. package/src/util/known-fixes.mjs +153 -0
  64. package/src/worker/engine.mjs +13 -8
  65. package/src/worker/loop.mjs +135 -39
  66. package/src/worker/queue.mjs +68 -7
@@ -8,14 +8,15 @@
8
8
  * Row conventions read here: `dispatch` {block,level,lane,lines}; `block.close`
9
9
  * {block,status,lines_actual} — ANY `block.close` row marks the block completed (a stop is
10
10
  * `block.stop`; B19); `review.plan` {block,tier,depth_unconstrained,depth_chosen,
11
- * degrade_step}; `session` {block,role,provider,level,tokens_in,tokens_out,cost_usd?} — one row per
12
- * coder/reviewer/judge/s2/author/facts spend; its dollars are `cost_usd` when the row carries it,
13
- * else ESTIMATED from its tokens × `./prices.mjs` (B19: live session rows carry tokens only); `review.done` {block,file,context_mode,findings_by_severity,round}; `decision`
11
+ * degrade_step}; `session` {block,role,provider,level,tokens_in,tokens_out,usd,usd_unknown?} — one row per
12
+ * coder/reviewer/judge/s2/author/facts spend (plus `ledger add coder` manual rows and
13
+ * `session.background` rows, whose price is unknown); its dollars come from `./spend.mjs`
14
+ * (`usd`, else `cost_usd`, else ESTIMATED from its tokens × `./prices.mjs`); `review.done` {block,file,context_mode,findings_by_severity,round}; `decision`
14
15
  * {block,question,source}; `outcome` {block,missed_after_degrade}; `escalation` {block,trigger};
15
16
  * `review.unavailable` {block,reason}; `proof` {block,duration_ms}; `run.stop` {reason}.
16
17
  */
17
18
 
18
- import { estimateCostUsd } from './prices.mjs';
19
+ import { isSpendRow, rowUsd, spendByRun, sumSpend } from './spend.mjs';
19
20
 
20
21
  const ROLE_BUCKET = Object.freeze({
21
22
  coder: 'coderUsd',
@@ -38,30 +39,60 @@ function countBy(rows, keyOf) {
38
39
  }
39
40
 
40
41
  /**
41
- * A session row's dollars: `cost_usd` when present, else tokens × the static price table; null when
42
- * neither is known (no token count, or a provider/level the table does not price).
42
+ * A session row's dollars (B33: its `usd`; null when `usd_unknown`; older rows: `cost_usd`, else
43
+ * tokens × the static price table); null when the price is unknown.
43
44
  * @param {Record<string, any>} row @returns {number | null}
44
45
  */
45
46
  export function sessionCostUsd(row) {
46
- if (typeof row.cost_usd === 'number') return row.cost_usd;
47
- const tokensIn = row.tokens_in ?? 0;
48
- const tokensOut = row.tokens_out ?? 0;
49
- if (row.tokens_in == null && row.tokens_out == null) return null;
50
- try {
51
- return estimateCostUsd({ provider: row.provider, level: row.level, tokensIn, tokensOut });
52
- } catch {
53
- return null;
54
- }
47
+ return rowUsd(row);
48
+ }
49
+
50
+ /**
51
+ * B33: spend per run (running totals, open blocks included) and over the whole ledger — kept
52
+ * beside the 13 sections, not as a 14th (`SECTION_NAMES` stays pinned at 13).
53
+ * @param {Array<Record<string, any>>} rows
54
+ * @returns {{runs: Array<{run: string | null, usd: number, unknown: number, sessions: number}>, total: {usd: number, unknown: number, sessions: number}}}
55
+ */
56
+ export function buildSpend(rows) {
57
+ const acted = rows.filter((r) => r.source !== 'shadow');
58
+ return { runs: spendByRun(acted), total: sumSpend(acted) };
55
59
  }
56
60
 
57
- /** Cost per SUCCESSFULLY COMPLETED block: review $ / coder $ / S1+S2 $ / facts $ / proof ms. */
61
+ /**
62
+ * Cost per block — completed AND still open (B33: a running total while the block is open):
63
+ * review $ / coder $ / S1+S2 $ / facts $ / proof ms, `completed` (any `block.close` row) and
64
+ * `unknownUsdSessions` (spend rows whose price is unknown, counted 0 in the totals). Grouped by
65
+ * run + block, so a block id reused in a second run is its own entry; a row without a run joins
66
+ * the entry of its block when that block id appears in exactly one run (else a `run: null` entry); shadow rows are dropped here too, exactly as `buildSpend` does. An entry is listed
67
+ * once it has a spend row or a `block.close` row.
68
+ */
58
69
  function costPerBlock(rows) {
59
70
  /** @type {Map<string, any>} */
60
71
  const byBlock = new Map();
72
+ /** @type {Set<string>} run+block keys with at least one spend row */
73
+ const spent = new Set();
74
+ // never split one block: a row without a run joins its block's entry when that block id appears
75
+ // in exactly one run; only a block seen in several runs leaves such a row in a `run: null` entry
76
+ /** @type {Map<string, Set<string>>} */
77
+ const runsOfBlock = new Map();
78
+ for (const row of rows) {
79
+ if (!row.block || row.source === 'shadow' || typeof row.run !== 'string') continue;
80
+ if (!runsOfBlock.has(row.block)) runsOfBlock.set(row.block, new Set());
81
+ /** @type {Set<string>} */ (runsOfBlock.get(row.block)).add(row.run);
82
+ }
83
+ /** @param {Record<string, any>} row @returns {string | null} */
84
+ const runOf = (row) => {
85
+ if (typeof row.run === 'string') return row.run;
86
+ const runs = runsOfBlock.get(row.block);
87
+ return runs && runs.size === 1 ? [...runs][0] : null;
88
+ };
61
89
  for (const row of rows) {
62
- if (!row.block) continue;
63
- if (!byBlock.has(row.block)) {
64
- byBlock.set(row.block, {
90
+ if (!row.block || row.source === 'shadow') continue;
91
+ const run = runOf(row);
92
+ const key = `${run ?? ''}\u0000${row.block}`;
93
+ if (!byBlock.has(key)) {
94
+ byBlock.set(key, {
95
+ run,
65
96
  block: row.block,
66
97
  level: null,
67
98
  lane: null,
@@ -73,26 +104,32 @@ function costPerBlock(rows) {
73
104
  otherUsd: 0,
74
105
  proofTimeMs: 0,
75
106
  totalUsd: 0,
107
+ unknownUsdSessions: 0,
76
108
  });
77
109
  }
78
- const e = byBlock.get(row.block);
110
+ const e = byBlock.get(key);
79
111
  if (row.event === 'dispatch') {
80
112
  e.level = row.level ?? e.level;
81
113
  e.lane = row.lane ?? e.lane;
82
114
  }
83
115
  if (row.event === 'block.close') e.completed = true;
84
- const cost = row.event === 'session' ? sessionCostUsd(row) : null;
85
- if (cost !== null) {
86
- // An unrecognized role still spent real money — bucket it as `otherUsd` instead of
87
- // silently dropping it from every total (fix round 1: an unknown role must not make
88
- // totalUsd under-report actual spend).
89
- const bucket = ROLE_BUCKET[row.role] ?? 'otherUsd';
90
- e[bucket] = (e[bucket] ?? 0) + cost;
91
- e.totalUsd += cost;
116
+ if (isSpendRow(row)) {
117
+ spent.add(key);
118
+ const cost = sessionCostUsd(row);
119
+ if (cost === null) {
120
+ e.unknownUsdSessions += 1;
121
+ } else {
122
+ // An unrecognized role still spent real money — bucket it as `otherUsd` instead of
123
+ // silently dropping it from every total (fix round 1: an unknown role must not make
124
+ // totalUsd under-report actual spend).
125
+ const bucket = ROLE_BUCKET[row.role] ?? 'otherUsd';
126
+ e[bucket] = (e[bucket] ?? 0) + cost;
127
+ e.totalUsd += cost;
128
+ }
92
129
  }
93
130
  if (row.event === 'proof' && typeof row.duration_ms === 'number') e.proofTimeMs += row.duration_ms;
94
131
  }
95
- const out = [...byBlock.values()].filter((e) => e.completed);
132
+ const out = [...byBlock.entries()].filter(([key, e]) => e.completed || spent.has(key)).map(([, e]) => e);
96
133
  for (const e of out) {
97
134
  for (const k of ['reviewUsd', 'coderUsd', 's1s2Usd', 'factsUsd', 'otherUsd', 'totalUsd']) e[k] = Math.round(e[k] * 10000) / 10000;
98
135
  }
@@ -0,0 +1,171 @@
1
+ /**
2
+ * Run spend in USD (B33, plan rule R6): the one place that turns ledger rows into dollars for
3
+ * `report`, `run status`, the `budget.usd` stop in `spawnSession`, and `ledger add coder`.
4
+ *
5
+ * Dollars are estimates from the static price table (`./prices.mjs`, provider × level, blended
6
+ * in+out per 1K tokens). A row whose price is unknown carries `usd: null, usd_unknown: true` —
7
+ * never a guess — and counts as 0 in totals while being counted in `unknown`.
8
+ *
9
+ * Spend rows: `event: 'session'` (one per foreground attempt, plus the manual/cloud coder rows
10
+ * `ledger add coder` writes with `manual: true`) and `event: 'session.background'` (a detached
11
+ * coder: its tokens are never seen, so its price is unknown). Shadow rows never count.
12
+ */
13
+
14
+ import { logWarning } from '../util/error-log.mjs';
15
+ import { writeSafe } from '../util/redact.mjs';
16
+ import { estimateCostUsd } from './prices.mjs';
17
+
18
+ /** The ledger events that carry a session's spend. */
19
+ export const SPEND_EVENTS = Object.freeze(['session', 'session.background']);
20
+
21
+ /** Round to 4 decimals (the price table's precision). @param {number} n */
22
+ export const roundUsd = (n) => Math.round(n * 10000) / 10000;
23
+
24
+ /** A usable amount: a finite number ≥ 0 (tokens or dollars). @param {unknown} v @returns {v is number} */
25
+ export const isAmount = (v) => typeof v === 'number' && Number.isFinite(v) && v >= 0;
26
+
27
+ /**
28
+ * Price one session from its token counts. Both counts must be finite numbers ≥ 0 — a null,
29
+ * missing, NaN, negative or string count is unknown (never read as 0) — and the provider/level
30
+ * must be in the price table; anything else ⇒ `{usd: null, usd_unknown: true}`.
31
+ * @param {{provider?: unknown, level?: unknown, tokens_in?: unknown, tokens_out?: unknown}} row
32
+ * @returns {{usd: number, usd_unknown: false} | {usd: null, usd_unknown: true}}
33
+ */
34
+ export function priceSession({ provider, level, tokens_in, tokens_out }) {
35
+ if (!isAmount(tokens_in) || !isAmount(tokens_out)) return { usd: null, usd_unknown: true };
36
+ try {
37
+ const usd = estimateCostUsd({ provider: /** @type {string} */ (provider), level: /** @type {string} */ (level), tokensIn: tokens_in, tokensOut: tokens_out });
38
+ return isAmount(usd) ? { usd, usd_unknown: false } : { usd: null, usd_unknown: true };
39
+ } catch {
40
+ return { usd: null, usd_unknown: true };
41
+ }
42
+ }
43
+
44
+ /** @param {Record<string, any>} row @returns {boolean} */
45
+ export const isSpendRow = (row) => SPEND_EVENTS.includes(row?.event) && row.source !== 'shadow';
46
+
47
+ /**
48
+ * A spend row's dollars, or null when unknown. `usd` when the row carries the key (a non-amount —
49
+ * null, NaN, Infinity, negative, a string — is unknown); `usd_unknown: true` is unknown; older rows
50
+ * (before B33): `cost_usd` when present (a non-amount is unknown), else tokens × the price table.
51
+ * @param {Record<string, any>} row @returns {number | null}
52
+ */
53
+ export function rowUsd(row) {
54
+ if (row.usd !== undefined) return isAmount(row.usd) ? row.usd : null;
55
+ if (row.usd_unknown === true) return null;
56
+ if (row.cost_usd !== undefined && row.cost_usd !== null) return isAmount(row.cost_usd) ? row.cost_usd : null;
57
+ if (row.event === 'session.background') return null;
58
+ return priceSession(row).usd;
59
+ }
60
+
61
+ /**
62
+ * Total spend of the rows: `usd` (unknown rows count 0), `unknown` (rows whose price is unknown),
63
+ * `sessions` (spend rows counted).
64
+ * @param {Array<Record<string, any>>} rows
65
+ * @returns {{usd: number, unknown: number, sessions: number}}
66
+ */
67
+ export function sumSpend(rows) {
68
+ let usd = 0;
69
+ let unknown = 0;
70
+ let sessions = 0;
71
+ for (const row of rows) {
72
+ if (!isSpendRow(row)) continue;
73
+ sessions += 1;
74
+ const cost = rowUsd(row);
75
+ if (cost === null) unknown += 1;
76
+ else usd += cost;
77
+ }
78
+ return { usd: roundUsd(usd), unknown, sessions };
79
+ }
80
+
81
+ /**
82
+ * Spend of one run (rows whose `run` is `runId`).
83
+ * @param {Array<Record<string, any>>} rows @param {string} runId
84
+ */
85
+ export const runSpend = (rows, runId) => sumSpend(rows.filter((r) => r.run === runId));
86
+
87
+ /**
88
+ * Spend per run, in first-seen order; rows without a run are grouped under `null`.
89
+ * @param {Array<Record<string, any>>} rows
90
+ * @returns {Array<{run: string | null, usd: number, unknown: number, sessions: number}>}
91
+ */
92
+ export function spendByRun(rows) {
93
+ /** @type {Map<string | null, Array<Record<string, any>>>} */
94
+ const groups = new Map();
95
+ for (const row of rows) {
96
+ if (!isSpendRow(row)) continue;
97
+ const key = typeof row.run === 'string' ? row.run : null;
98
+ if (!groups.has(key)) groups.set(key, []);
99
+ /** @type {Array<Record<string, any>>} */ (groups.get(key)).push(row);
100
+ }
101
+ return [...groups.entries()].map(([run, list]) => ({ run, ...sumSpend(list) }));
102
+ }
103
+
104
+ /**
105
+ * `budget.usd` from a loaded config: a finite number > 0, else null (no budget).
106
+ * @param {Record<string, any> | null | undefined} cfg @returns {number | null}
107
+ */
108
+ export function budgetUsdOf(cfg) {
109
+ const v = cfg?.budget?.usd;
110
+ return typeof v === 'number' && Number.isFinite(v) && v > 0 ? v : null;
111
+ }
112
+
113
+ /** The plain refusal text (R6). @param {number} budget @param {number} spent */
114
+ export const budgetReachedMessage = (budget, spent) => `budget.usd ${budget.toFixed(2)} reached (spent ${spent.toFixed(2)}); raise budget.usd or end the run`;
115
+
116
+ /** The one-time warning text at 80 %. @param {number} budget @param {number} spent */
117
+ export const budgetWarningMessage = (budget, spent) => `budget.usd: spent ${spent.toFixed(2)} of ${budget.toFixed(2)} (≥ 80%); new sessions stop at 100%`;
118
+
119
+ /**
120
+ * Runs this process warned about — the FALLBACK dedupe, used only when the warning row could not
121
+ * be written (no writer, or the write failed). The primary record is the `budget.warning` row.
122
+ */
123
+ const warnedRuns = new Set();
124
+
125
+ /**
126
+ * The budget check run before every session spawn. Reads the run's spend from `rows` (only rows
127
+ * whose `run` is `run` count): spent ≥ budget ⇒ `{refuse: true}` and a `budget.refused` row;
128
+ * spent ≥ 80 % of budget ⇒ one `budget.warning` row and stderr line per run — skipped when the
129
+ * ledger already holds a `budget.warning` row for the run.
130
+ * @param {{budget: number, run: string, rows: Array<Record<string, any>>, block?: string | null, role?: string,
131
+ * writeRow?: ((row: Record<string, any>) => Promise<unknown>) | null, stderr: {write: (s: string) => unknown}}} opts
132
+ * @returns {Promise<{refuse: boolean, spent: number, message?: string}>}
133
+ */
134
+ export async function checkBudget({ budget, run, rows, block = null, role, writeRow = null, stderr }) {
135
+ const { usd: spent, unknown } = runSpend(rows, run);
136
+ const base = { run, block, ...(role ? { role } : {}), budget_usd: budget, spent_usd: spent, unknown_usd_sessions: unknown };
137
+ if (spent >= budget) {
138
+ const message = budgetReachedMessage(budget, spent);
139
+ writeSafe(stderr, `code-forge: ${message}\n`);
140
+ if (writeRow) {
141
+ try {
142
+ await writeRow({ event: 'budget.refused', ...base });
143
+ } catch {
144
+ // the refusal stands without its row
145
+ }
146
+ }
147
+ return { refuse: true, spent, message };
148
+ }
149
+ // compared at the ledger's 4-decimal precision, so 0.24 of 0.30 is exactly 80 %
150
+ if (spent >= roundUsd(0.8 * budget) && !rows.some((r) => r.event === 'budget.warning' && r.run === run)) {
151
+ let recorded = false;
152
+ if (writeRow) {
153
+ try {
154
+ await writeRow({ event: 'budget.warning', ...base });
155
+ recorded = true;
156
+ } catch {
157
+ recorded = false; // fall back to the in-process dedupe below
158
+ }
159
+ }
160
+ if (recorded || !warnedRuns.has(run)) {
161
+ writeSafe(stderr, `code-forge: ${budgetWarningMessage(budget, spent)}\n`);
162
+ warnedRuns.add(run); // after the write attempt, never before
163
+ // B37: a warning line in the local error log — never the amounts (config values)
164
+ await logWarning({ warning: 'budget_warning', message: 'budget.usd: 80% of the run budget is spent; new sessions stop at 100%' }).catch(() => null);
165
+ }
166
+ }
167
+ return { refuse: false, spent };
168
+ }
169
+
170
+ /** The fail-closed refusal text: a budget is set but the run's spend cannot be read. */
171
+ export const SPEND_UNREADABLE_MESSAGE = "budget.usd is set but the run's spend cannot be read; not starting a session";
@@ -5,7 +5,8 @@
5
5
  * 1. plans the sessions (`planSessions`):
6
6
  * adaptive (default) — `risk < 1` ⇒ one L2 `quick`; `1 ≤ risk < 2` ⇒ one L2 `full`;
7
7
  * `risk ≥ 2` ⇒ two blind L2 sessions (lens `A`, lens `B`) + one L3 `judge`;
8
- * consensus (`review.multimodel: true`) — one L2 `full` per EFFECTIVE provider
8
+ * consensus (`review.multimodel: true`, risk above `review.single_reviewer_max_risk` (1),
9
+ * and not a docs/contract block unless `review.multimodel_for_docs`; B34) — one L2 `full` per EFFECTIVE provider
9
10
  * (`resolve(L2)` and `review.second_levels.L2` / `review.second_provider`) + one L3 judge;
10
11
  * the same effective provider twice is refused (`consensus-same-provider`);
11
12
  * 2. reads the diff once and assembles one packet per lens (`packet.mjs`);
@@ -15,7 +16,10 @@
15
16
  * is `unavailable` with its reason and is NEVER approval; a failed lens means no judge;
16
17
  * 5. a valid answer with `needs_file` gets ONE more round with those files attached (git-tracked
17
18
  * files only, never secret-like paths — `attachFiles`); a second `needs_file` is ignored; a
18
- * request with a non-repo-relative path is refused (`needs_file-refused`, never approval).
19
+ * request with a non-repo-relative path is refused (`needs_file-refused`, never approval);
20
+ * 6. a session that ends in `timeout` is spawned ONCE more on the same packet and level
21
+ * (`session-retry.mjs`, B30); a second timeout is `unavailable: timeout`. The summary's
22
+ * `attempts` says how many ran.
19
23
  * Approval (until B12b's triage lands) = the final answer (the judge's in dual/consensus mode, else
20
24
  * the single reviewer's) passed the guard, says `passed: true`, and carries no `critical` or
21
25
  * `warning` finding.
@@ -25,10 +29,12 @@ import { mkdirSync, rmSync, writeFileSync } from 'node:fs';
25
29
  import { randomBytes } from 'node:crypto';
26
30
  import path from 'node:path';
27
31
  import { resolveLevel } from '../config/known-ids.mjs';
32
+ import { blockKind, isDocsKind } from '../decide/escalation.mjs';
28
33
  import { fallbackRisk } from '../decide/fallback-rules.mjs';
29
34
  import { tierFor } from '../proof/tiers.mjs';
30
35
  import { assembleJudgePacket, assemblePacket, attachFiles, budgetFor, readFileDiff } from './packet.mjs';
31
36
  import { FINDING_SCHEMA, minTokensOut, SMALL_DIFF_ADDED_LINES, validateReview } from './validate-review.mjs';
37
+ import { spawnWithTimeoutRetry } from './session-retry.mjs';
32
38
  import { assertRowPath } from '../worker/ticket.mjs';
33
39
 
34
40
  /**
@@ -60,13 +66,31 @@ export function secondLevel(cfg) {
60
66
  }
61
67
 
62
68
  /**
63
- * @param {{risk: number, cfg: Record<string, any>}} opts
69
+ * `review.single_reviewer_max_risk` (B34, default 1): at or below it ONE reviewer runs, no judge.
70
+ * @param {Record<string, any> | undefined} cfg @returns {number}
71
+ */
72
+ export function singleReviewerMaxRisk(cfg) {
73
+ const v = cfg?.review?.single_reviewer_max_risk;
74
+ // `validate` rejects anything outside 0–3 (schema); this guard only covers an unvalidated config
75
+ return typeof v === 'number' && Number.isFinite(v) && v >= 0 && v <= 3 ? v : 1;
76
+ }
77
+
78
+ /**
79
+ * The review topology — the ONE place it is decided (B34):
80
+ * - consensus runs only when `review.multimodel` is true, the block is not a docs/contract block
81
+ * (unless `review.multimodel_for_docs` is true), and `risk > review.single_reviewer_max_risk`;
82
+ * - otherwise adaptive: `risk ≤ single_reviewer_max_risk` or `risk < 2` ⇒ one L2 session
83
+ * (`quick` below risk 1, else `full`); else two blind lenses + an L3 judge.
84
+ * @param {{risk: number, cfg: Record<string, any>, kind?: unknown}} opts - `kind`: the block kind
85
+ * (`blockKind`), default `code`.
64
86
  * @returns {SessionPlan}
65
87
  */
66
- export function planSessions({ risk, cfg }) {
88
+ export function planSessions({ risk, cfg, kind = 'code' }) {
67
89
  if (typeof risk !== 'number' || !Number.isFinite(risk) || risk < 0 || risk > 3) throw new TypeError('planSessions: risk must be a number from 0 to 3');
68
90
  const judge = /** @type {SessionSpec} */ ({ lens: 'judge', role: 'judge', level: 'L3', slot: null });
69
- if (cfg?.review?.multimodel === true) {
91
+ const single = risk <= singleReviewerMaxRisk(cfg);
92
+ const docsOff = isDocsKind(kind) && cfg?.review?.multimodel_for_docs !== true;
93
+ if (cfg?.review?.multimodel === true && !single && !docsOff) {
70
94
  const second = secondLevel(cfg);
71
95
  if (!second.ok) return { mode: 'consensus', depth: null, sessions: [], refused: second.reason };
72
96
  return {
@@ -80,7 +104,7 @@ export function planSessions({ risk, cfg }) {
80
104
  };
81
105
  }
82
106
  if (risk < 1) return { mode: 'adaptive', depth: 'quick', sessions: [{ lens: 'quick', role: 'reviewer', level: 'L2', slot: null }] };
83
- if (risk < 2) return { mode: 'adaptive', depth: 'full', sessions: [{ lens: 'full', role: 'reviewer', level: 'L2', slot: null }] };
107
+ if (risk < 2 || single) return { mode: 'adaptive', depth: 'full', sessions: [{ lens: 'full', role: 'reviewer', level: 'L2', slot: null }] };
84
108
  return {
85
109
  mode: 'adaptive',
86
110
  depth: 'dual',
@@ -117,6 +141,7 @@ export function rulesRisk({ file, plusCount, cfg }) {
117
141
  * @property {string | null} [base] - the block's base SHA (null ⇒ HEAD).
118
142
  * @property {Record<string, any>} cfg
119
143
  * @property {number} [risk] - the S1 risk (0–3); absent ⇒ the rules risk.
144
+ * @property {string} [kind] - the block kind (`code`, `docs`, `contract`; B34); absent ⇒ `blockKind` of the reviewed file.
120
145
  * @property {string} [rulesDigest] @property {string} [factsExcerpt]
121
146
  * @property {string} workDir - where packet files are written (under the run's temp root).
122
147
  */
@@ -161,7 +186,9 @@ export async function reviewFile(input, deps) {
161
186
  const { repoRoot, cfg, workDir } = input;
162
187
  const diff = await readFileDiff({ repoRoot, file: input.file, base: input.base ?? null });
163
188
  const risk = input.risk ?? rulesRisk({ file: diff.file, plusCount: diff.plusCount, cfg });
164
- const plan = planSessions({ risk, cfg });
189
+ // no recorded kind ⇒ the reviewed file's own kind (a lone `.md` is a docs review), never a blind `code`
190
+ const kind = typeof input.kind === 'string' ? input.kind : blockKind({ owned: [diff.file] });
191
+ const plan = planSessions({ risk, cfg, kind });
165
192
  const head = { engine: plan.mode, depth: plan.depth, risk, file: diff.file };
166
193
  if (plan.refused) return { status: 'refused', reason: plan.refused, approved: false, ...head, sessions: [] };
167
194
  if (diff.diffText.trim().length === 0) return { status: 'no_change', approved: false, ...head, sessions: [] };
@@ -231,17 +258,30 @@ async function runOne(spec, text, packet, { deps, workDir, min, smallDiff = fals
231
258
  writeFileSync(promptPath, text, { mode: 0o600 });
232
259
  /** @type {Record<string, any> | null} */
233
260
  let res = null;
261
+ let attempts = 0;
234
262
  try {
235
- res = await deps.spawn({
236
- level: spec.level,
237
- role: spec.role,
238
- promptPath,
239
- schema: FINDING_SCHEMA,
240
- rowExtra: { lens: spec.lens, context_mode: packet.contextMode, ctx_tokens_in: packet.tokensIn },
241
- ...(spec.cfg ? { cfg: spec.cfg } : {}),
242
- });
263
+ // a `timeout` is spawned once more on the same packet and level (B30); a spawn that throws
264
+ // (bad config, refused argv) is an `exit` failure, never approval
265
+ ({ res, attempts } = await spawnWithTimeoutRetry(
266
+ deps.spawn,
267
+ {
268
+ level: spec.level,
269
+ role: spec.role,
270
+ promptPath,
271
+ schema: FINDING_SCHEMA,
272
+ rowExtra: { lens: spec.lens, context_mode: packet.contextMode, ctx_tokens_in: packet.tokensIn },
273
+ ...(spec.cfg ? { cfg: spec.cfg } : {}),
274
+ },
275
+ (row) => note(deps, row),
276
+ (n) => {
277
+ attempts = n;
278
+ },
279
+ ));
243
280
  } catch {
244
- res = null; // a spawn that throws (bad config, refused argv) is an `exit` failure, never approval
281
+ // defensive only: the helper never throws. If it ever did, it is still an `exit` failure,
282
+ // never approval, with the attempts it had reported so far (at least 1).
283
+ res = null;
284
+ attempts = Math.max(attempts, 1);
245
285
  } finally {
246
286
  rmSync(promptPath, { force: true });
247
287
  }
@@ -251,6 +291,7 @@ async function runOne(spec, text, packet, { deps, workDir, min, smallDiff = fals
251
291
  provider: res?.provider ?? null,
252
292
  model: res?.model ?? null,
253
293
  fallback_step: res?.fallback_step ?? 0,
294
+ attempts,
254
295
  tokens_in: res?.usage?.tokens_in ?? null,
255
296
  tokens_out: res?.usage?.tokens_out ?? null,
256
297
  };
@@ -14,7 +14,10 @@
14
14
  * round (`review.late_findings: sweep`, default; `block` makes it an open finding instead).
15
15
  * 3. Strictly shrinking: open(n) ≥ open(n−1) ⇒ `trigger: review_stall` ⇒ +1 level now (rule 2b).
16
16
  * 4. Ladder: `escalation.review_rounds_per_level` (2) exhausted with an open finding ⇒ +1 level
17
- * (rule 2); the next rounds are coded at that level by a fresh session (`deps.fix`).
17
+ * (rule 2); the next rounds are coded at that level by a fresh session (`deps.fix`). Below L2,
18
+ * `escalation.after_rounds_with_warnings` (1) heavy rounds at the level — an open set with
19
+ * ≥ `escalation.warning_threshold` (2) warnings or any `critical` — climb at once (rule 2c,
20
+ * B34: `trigger: review_warnings`); `warn_rounds_at_level` counts them and resets on a climb.
18
21
  * 5. Cap: `review.max_rounds_per_file` (4) reached with an open finding ⇒ the L3 patch rung
19
22
  * (rule 6; once per block — shared with rule 5's ceiling), whose `patch_check` round is
20
23
  * OUTSIDE the cap. The rung is also rule 5: rule 2/2b at the running ceiling (L2 — L3 is never
@@ -49,13 +52,14 @@
49
52
  import { mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
50
53
  import { randomBytes } from 'node:crypto';
51
54
  import path from 'node:path';
52
- import { escalationAfterAttempt, reviewRoundStalled } from '../decide/escalation.mjs';
55
+ import { escalationAfterAttempt, heavyRound, reviewRoundStalled } from '../decide/escalation.mjs';
53
56
  import { exec } from '../util/exec.mjs';
54
57
  import { contentHash, gitChildEnv } from '../worker/ticket.mjs';
55
58
  import { parseDiff } from './context.mjs';
56
59
  import { assemblePacket, assertPacketPath, readFileDiff } from './packet.mjs';
57
60
  import { cameFromJudge, resolvedBand, triageFindings } from './triage.mjs';
58
61
  import { FINDING_SCHEMA, minTokensOut, validateReview } from './validate-review.mjs';
62
+ import { spawnWithTimeoutRetry } from './session-retry.mjs';
59
63
 
60
64
  /** @typedef {import('./triage.mjs').Finding} Finding */
61
65
  /** @typedef {import('./context.mjs').Hunk} Hunk */
@@ -74,6 +78,7 @@ import { FINDING_SCHEMA, minTokensOut, validateReview } from './validate-review.
74
78
  * @property {string} level - the level the file's fixes are coded at now.
75
79
  * @property {number} round - rounds counted so far (patch_check rounds excluded).
76
80
  * @property {number} rounds_at_level
81
+ * @property {number} [warn_rounds_at_level] - heavy rounds at the current level (rule 2c, B34); absent in a state saved before B34 ⇒ 0.
77
82
  * @property {Finding[]} open - the open `fix_now` set.
78
83
  * @property {Finding[]} late
79
84
  * @property {string | null} reviewed_content - the content the last counted round reviewed.
@@ -119,6 +124,8 @@ function settings(cfg) {
119
124
  scope: r.recheck_scope === 'file' ? 'file' : 'fix_hunks',
120
125
  late: r.late_findings === 'block' ? 'block' : 'sweep',
121
126
  perLevel: int(e.review_rounds_per_level, 2),
127
+ warnAfter: int(e.after_rounds_with_warnings, 1),
128
+ warnThreshold: e.warning_threshold, // heavyRound falls back to 2 for anything but an integer ≥ 1
122
129
  stopAt: typeof e.stop_at === 'string' ? e.stop_at : 'L3',
123
130
  l3Mode: e.l3_mode === 'code' ? 'code' : 'patch',
124
131
  };
@@ -129,7 +136,7 @@ function settings(cfg) {
129
136
  * @returns {FileState}
130
137
  */
131
138
  export function newFileState({ file, level, l3RungUsed = false }) {
132
- return { file, level, round: 0, rounds_at_level: 0, open: [], late: [], reviewed_content: null, l3_rung_used: l3RungUsed, status: 'open', next: null, last_packet: null, pending_kind: null };
139
+ return { file, level, round: 0, rounds_at_level: 0, warn_rounds_at_level: 0, open: [], late: [], reviewed_content: null, l3_rung_used: l3RungUsed, status: 'open', next: null, last_packet: null, pending_kind: null };
133
140
  }
134
141
 
135
142
  /** @param {string} repoRoot @param {string} file @returns {{rel: string, content: string, hash: string}} */
@@ -209,11 +216,18 @@ async function recheckSession(packet, deps) {
209
216
  /** @type {Record<string, any> | null} */
210
217
  let res = null;
211
218
  try {
219
+ // a `timeout` is spawned once more on the same packet and level (B30)
212
220
  res = deps.spawn
213
- ? await deps.spawn({ level: 'L2', role: 'reviewer', promptPath, schema: FINDING_SCHEMA, rowExtra: { lens: 'recheck', context_mode: packet.contextMode, ctx_tokens_in: packet.tokensIn } })
221
+ ? (
222
+ await spawnWithTimeoutRetry(
223
+ deps.spawn,
224
+ { level: 'L2', role: 'reviewer', promptPath, schema: FINDING_SCHEMA, rowExtra: { lens: 'recheck', context_mode: packet.contextMode, ctx_tokens_in: packet.tokensIn } },
225
+ (row) => note(deps, row),
226
+ )
227
+ ).res
214
228
  : null;
215
229
  } catch {
216
- res = null;
230
+ res = null; // the helper never throws; if it ever did, it is still an `exit` failure
217
231
  } finally {
218
232
  rmSync(promptPath, { force: true });
219
233
  }
@@ -246,12 +260,17 @@ function decideNext(state, openBefore, s, kind) {
246
260
  openFixNowFinding: true,
247
261
  reviewRoundsAtLevel: state.rounds_at_level,
248
262
  reviewRoundsPerLevel: s.perLevel,
263
+ warningRoundsAtLevel: state.warn_rounds_at_level ?? 0,
264
+ afterRoundsWithWarnings: s.warnAfter,
249
265
  stopAt: s.stopAt,
250
266
  l3Mode: /** @type {'patch' | 'code'} */ (s.l3Mode),
251
267
  l3RungAlreadyUsed: state.l3_rung_used,
252
268
  });
253
269
  if (esc.action === 'escalate' && esc.level) {
254
- if (esc.level !== state.level) state.rounds_at_level = 0;
270
+ if (esc.level !== state.level) {
271
+ state.rounds_at_level = 0;
272
+ state.warn_rounds_at_level = 0;
273
+ }
255
274
  state.level = esc.level;
256
275
  return { action: 'fix', level: state.level, trigger: esc.trigger };
257
276
  }
@@ -390,10 +409,13 @@ export async function runRound(state, deps, opts = {}) {
390
409
  if (kind !== 'patch_check') {
391
410
  draft.round += 1;
392
411
  draft.rounds_at_level += 1;
412
+ // THIS round's open set, after triage: only the warnings still open count
413
+ if (heavyRound(draft.open, s.warnThreshold)) draft.warn_rounds_at_level = (draft.warn_rounds_at_level ?? 0) + 1;
393
414
  } else {
394
415
  // the block continues at L2 after the rung with a fresh round counter (§3.6) — also here, not
395
416
  // only in `converge`, because the worker runs the patch_check from its own ticket
396
417
  draft.rounds_at_level = 0;
418
+ draft.warn_rounds_at_level = 0;
397
419
  }
398
420
  draft.reviewed_content = current.content;
399
421
  draft.last_packet = packetInfo;
@@ -456,6 +478,7 @@ export async function converge(state, deps) {
456
478
  if (next.action === 'patch') {
457
479
  state.l3_rung_used = true;
458
480
  state.rounds_at_level = 0;
481
+ state.warn_rounds_at_level = 0;
459
482
  await deps.patch?.({ file: state.file, level: 'L3', open: [...state.open] });
460
483
  await runRound(state, deps, { kind: 'patch_check' });
461
484
  continue;
@@ -0,0 +1,69 @@
1
+ /**
2
+ * One review session with ONE automatic retry on `timeout` (block B30, issue #2).
3
+ *
4
+ * Field data: a reviewer CLI can hang for the whole `review.session_timeout_s` with almost no
5
+ * output, while the same packet reviews in seconds on a second try. So a session whose status is
6
+ * `timeout` is spawned once more with the SAME options (same packet file, same level, same lens);
7
+ * only when the second attempt times out too does the review report `unavailable: timeout`. Any
8
+ * other failure (exit, schema, unavailable…) is never retried here.
9
+ *
10
+ * Both attempts are recorded: `spawnSession` writes one ledger `session` row per spawn as always,
11
+ * and every timed-out attempt adds a `review.session_timeout` note `{lens, role, level, provider,
12
+ * model, attempt, retried, duration_ms, stderr_tail, tail_note?, stdout_bytes, stdout_events}`:
13
+ * the last 4 KB of the session's stderr, packet-free and redacted (`stderrTail`; null with a
14
+ * `tail_note` when withheld), and stdout only as its byte count and last event types
15
+ * (`stdoutEvents`) — never stdout text. The helper never throws: a throwing spawn is returned as
16
+ * `res: null` and a failed note is ignored. `onAttempt(n)` is told each attempt as it starts.
17
+ */
18
+
19
+ import { logWarning } from '../util/error-log.mjs';
20
+
21
+ /** How many times a timed-out session is spawned again. */
22
+ export const TIMEOUT_RETRIES = 1;
23
+
24
+ /**
25
+ * @param {(opts: Record<string, any>) => Promise<Record<string, any>>} spawn
26
+ * @param {Record<string, any>} opts - the spawn options; passed unchanged to every attempt.
27
+ * @param {(row: Record<string, any>) => Promise<unknown>} note - best-effort ledger note.
28
+ * @param {(attempt: number) => void} [onAttempt] - told the attempt number as each one starts.
29
+ * @returns {Promise<{res: Record<string, any> | null, attempts: number}>} the last attempt's result
30
+ * (`null` when the spawn threw) and how many attempts ran.
31
+ */
32
+ export async function spawnWithTimeoutRetry(spawn, opts, note, onAttempt = () => {}) {
33
+ let attempts = 0;
34
+ for (;;) {
35
+ attempts += 1;
36
+ onAttempt(attempts);
37
+ /** @type {Record<string, any> | null} */
38
+ let res;
39
+ try {
40
+ res = await spawn(opts);
41
+ } catch {
42
+ return { res: null, attempts }; // a throw is an `exit` failure, never retried
43
+ }
44
+ if (res?.status !== 'timeout') return { res, attempts };
45
+ const retried = attempts <= TIMEOUT_RETRIES;
46
+ try {
47
+ await note({
48
+ event: 'review.session_timeout',
49
+ lens: opts?.rowExtra?.lens ?? null,
50
+ role: opts?.role ?? null,
51
+ level: opts?.level ?? null,
52
+ provider: res.provider ?? null,
53
+ model: res.model ?? null,
54
+ attempt: attempts,
55
+ retried,
56
+ duration_ms: typeof res.duration_ms === 'number' ? res.duration_ms : null,
57
+ stderr_tail: typeof res.stderr_tail === 'string' ? res.stderr_tail : null,
58
+ ...(typeof res.tail_note === 'string' ? { tail_note: res.tail_note } : {}),
59
+ stdout_bytes: typeof res.stdout_bytes === 'number' ? res.stdout_bytes : null,
60
+ stdout_events: Array.isArray(res.stdout_events) ? res.stdout_events : [],
61
+ });
62
+ } catch {
63
+ // a failed note never stops the retry or the result
64
+ }
65
+ if (!retried) return { res, attempts };
66
+ // B37: the retry is a recoverable problem: one warning line in the local error log
67
+ await logWarning({ warning: 'review_retry', message: 'a review session timed out; it was retried once' }).catch(() => null);
68
+ }
69
+ }