@codedology/code-forge 0.3.1 → 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.
- package/CHANGELOG.md +60 -0
- package/README.md +79 -21
- package/bin/code-forge.mjs +6 -1
- package/docs/README.md +5 -4
- package/docs/reference/blocks.json +153 -1
- package/docs/reference/config.md +8 -1
- package/package.json +4 -2
- package/schema/code-forge.schema.json +15 -4
- package/skill/SKILL.md +7 -7
- package/skill/references/adapters/claude-code.md +1 -1
- package/skill/references/adapters/solo.md +1 -1
- package/skill/references/adapters/subprocess.md +1 -1
- package/skill/references/code.md +4 -3
- package/skill/references/continuity.md +4 -0
- package/skill/references/decisions.md +5 -2
- package/skill/references/ledger.md +8 -2
- package/skill/references/plan.md +3 -3
- package/skill/references/review.md +8 -5
- package/skill/references/security.md +3 -1
- package/skill/scripts/forge +1 -1
- package/skill/templates/coder-brief.md +36 -0
- package/skill/templates/plan.md +2 -0
- package/src/cli/block.mjs +51 -8
- package/src/cli/jev.mjs +114 -17
- package/src/cli/ledger.mjs +99 -8
- package/src/cli/logs.mjs +193 -58
- package/src/cli/plan.mjs +27 -11
- package/src/cli/report.mjs +15 -1
- package/src/cli/resolve.mjs +16 -1
- package/src/cli/review-file.mjs +10 -5
- package/src/cli/run.mjs +109 -8
- package/src/config/closed-book.mjs +75 -0
- package/src/config/validate.mjs +128 -7
- package/src/decide/escalation.mjs +112 -11
- package/src/decide/jev-client.mjs +1 -1
- package/src/decide/questions.mjs +39 -3
- package/src/doctor/probes.mjs +48 -6
- package/src/engines/builders/claude.mjs +4 -1
- package/src/engines/builders/codex.mjs +26 -11
- package/src/engines/builders/grok.mjs +3 -3
- package/src/engines/efforts.mjs +109 -0
- package/src/engines/sentinel.mjs +34 -0
- package/src/keys/onepassword.mjs +3 -0
- package/src/ledger/report.mjs +66 -29
- package/src/ledger/spend.mjs +171 -0
- package/src/review/engine.mjs +65 -23
- package/src/review/fixloop.mjs +29 -6
- package/src/review/session-retry.mjs +69 -0
- package/src/review/triage.mjs +26 -1
- package/src/review/validate-review.mjs +21 -6
- package/src/session/author.mjs +6 -2
- package/src/session/plan-check.mjs +71 -6
- package/src/session/plan-sections.mjs +98 -0
- package/src/session/spawn.mjs +217 -8
- package/src/state/block.mjs +13 -4
- package/src/state/config-snapshot.mjs +150 -0
- package/src/state/run.mjs +86 -0
- package/src/util/breadcrumbs.mjs +145 -0
- package/src/util/error-hint.mjs +3 -2
- package/src/util/error-kind.mjs +2 -2
- package/src/util/error-log.mjs +109 -18
- package/src/util/forbidden.mjs +3 -1
- package/src/util/known-fixes.json +1 -0
- package/src/util/known-fixes.mjs +153 -0
- package/src/worker/engine.mjs +13 -8
- package/src/worker/loop.mjs +135 -39
- package/src/worker/queue.mjs +68 -7
package/src/keys/onepassword.mjs
CHANGED
|
@@ -12,6 +12,7 @@
|
|
|
12
12
|
*/
|
|
13
13
|
|
|
14
14
|
import { reportErrorKind } from '../util/error-kind.mjs';
|
|
15
|
+
import { logWarning } from '../util/error-log.mjs';
|
|
15
16
|
import { exec as realExec } from '../util/exec.mjs';
|
|
16
17
|
|
|
17
18
|
// Long enough for a person to see and approve the 1Password prompt (Touch ID or password).
|
|
@@ -172,6 +173,8 @@ async function runOp(argv, exec, timeoutMs) {
|
|
|
172
173
|
attempts += 1;
|
|
173
174
|
res = await exec(argv, { timeoutMs });
|
|
174
175
|
} while (res.result !== 'ok' && res.timedOut && attempts === 1);
|
|
176
|
+
// B37: a call that needed its retry is a recoverable problem worth a warning line
|
|
177
|
+
if (attempts > 1) await logWarning({ warning: 'op_retry', message: '1Password did not answer in time; the call was retried once' }).catch(() => null);
|
|
175
178
|
return { res, attempts };
|
|
176
179
|
}
|
|
177
180
|
|
package/src/ledger/report.mjs
CHANGED
|
@@ -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,
|
|
12
|
-
* coder/reviewer/judge/s2/author/facts spend
|
|
13
|
-
*
|
|
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 {
|
|
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: `
|
|
42
|
-
*
|
|
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
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
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
|
-
/**
|
|
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
|
-
|
|
64
|
-
|
|
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(
|
|
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
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
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.
|
|
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";
|
package/src/review/engine.mjs
CHANGED
|
@@ -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
|
|
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
|
-
import { FINDING_SCHEMA, minTokensOut, validateReview } from './validate-review.mjs';
|
|
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
|
-
*
|
|
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
|
-
|
|
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,17 +186,20 @@ 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
|
-
|
|
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: [] };
|
|
168
195
|
mkdirSync(workDir, { recursive: true, mode: 0o700 });
|
|
169
196
|
const min = minTokensOut(cfg);
|
|
197
|
+
const smallDiff = diff.plusCount <= SMALL_DIFF_ADDED_LINES;
|
|
170
198
|
|
|
171
199
|
/** @param {SessionSpec} spec @param {{text: string, hunkHeaders: string[], tokensIn: number, contextMode: string | null}} packet */
|
|
172
200
|
const run = async (spec, packet) => {
|
|
173
201
|
/** @type {Record<string, any>} */
|
|
174
|
-
let summary = await runOne(spec, packet.text, packet, { deps, workDir, min, cfg });
|
|
202
|
+
let summary = await runOne(spec, packet.text, packet, { deps, workDir, min, smallDiff, cfg });
|
|
175
203
|
const needs = summary.review?.needs_file;
|
|
176
204
|
if (spec.role === 'reviewer' && summary.status === 'ok' && Array.isArray(needs) && needs.length > 0) {
|
|
177
205
|
const paths = safeNeeds(needs);
|
|
@@ -182,7 +210,7 @@ export async function reviewFile(input, deps) {
|
|
|
182
210
|
extra = null;
|
|
183
211
|
}
|
|
184
212
|
if (extra === null) return { ...summary, status: 'unavailable', reason: 'needs_file-refused', review: null };
|
|
185
|
-
summary = { ...(await runOne(spec, `${packet.text}${extra}`, packet, { deps, workDir, min, cfg })), needs_file_round: true };
|
|
213
|
+
summary = { ...(await runOne(spec, `${packet.text}${extra}`, packet, { deps, workDir, min, smallDiff, cfg })), needs_file_round: true };
|
|
186
214
|
}
|
|
187
215
|
return summary;
|
|
188
216
|
};
|
|
@@ -206,7 +234,7 @@ export async function reviewFile(input, deps) {
|
|
|
206
234
|
if (judgeSpec) {
|
|
207
235
|
const bySlot = Object.fromEntries(done.map((s) => [s.slot, s.review]));
|
|
208
236
|
const packet = assembleJudgePacket({ diff, reports: { A: bySlot.A, B: bySlot.B }, cfg });
|
|
209
|
-
final = await runOne(judgeSpec, packet.text, packet, { deps, workDir, min, cfg });
|
|
237
|
+
final = await runOne(judgeSpec, packet.text, packet, { deps, workDir, min, smallDiff, cfg });
|
|
210
238
|
sessions.push(publicSummary(final));
|
|
211
239
|
if (final.status !== 'ok') {
|
|
212
240
|
await note(deps, { event: 'review.unavailable', reason: final.reason, lens: 'judge' });
|
|
@@ -222,34 +250,48 @@ export async function reviewFile(input, deps) {
|
|
|
222
250
|
* One session: packet file (0600, removed after), spawn, stub guard.
|
|
223
251
|
* @param {SessionSpec} spec @param {string} text
|
|
224
252
|
* @param {{hunkHeaders: string[], tokensIn: number, contextMode: string | null}} packet
|
|
225
|
-
* @param {{deps: ReviewDeps, workDir: string, min: number, cfg: Record<string, any>}} env
|
|
253
|
+
* @param {{deps: ReviewDeps, workDir: string, min: number, smallDiff?: boolean, cfg: Record<string, any>}} env
|
|
226
254
|
*/
|
|
227
|
-
async function runOne(spec, text, packet, { deps, workDir, min }) {
|
|
255
|
+
async function runOne(spec, text, packet, { deps, workDir, min, smallDiff = false }) {
|
|
228
256
|
const promptPath = path.join(workDir, `${spec.lens}-${randomBytes(6).toString('hex')}.md`);
|
|
229
257
|
const base = { lens: spec.lens, role: spec.role, level: spec.level, slot: spec.slot, context_mode: packet.contextMode, ctx_tokens_in: packet.tokensIn };
|
|
230
258
|
writeFileSync(promptPath, text, { mode: 0o600 });
|
|
231
259
|
/** @type {Record<string, any> | null} */
|
|
232
260
|
let res = null;
|
|
261
|
+
let attempts = 0;
|
|
233
262
|
try {
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
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
|
+
));
|
|
242
280
|
} catch {
|
|
243
|
-
|
|
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);
|
|
244
285
|
} finally {
|
|
245
286
|
rmSync(promptPath, { force: true });
|
|
246
287
|
}
|
|
247
|
-
const verdict = validateReview(res, { hunkHeaders: packet.hunkHeaders, minTokensOut: min });
|
|
288
|
+
const verdict = validateReview(res, { hunkHeaders: packet.hunkHeaders, minTokensOut: min, smallDiff });
|
|
248
289
|
const meta = {
|
|
249
290
|
...base,
|
|
250
291
|
provider: res?.provider ?? null,
|
|
251
292
|
model: res?.model ?? null,
|
|
252
293
|
fallback_step: res?.fallback_step ?? 0,
|
|
294
|
+
attempts,
|
|
253
295
|
tokens_in: res?.usage?.tokens_in ?? null,
|
|
254
296
|
tokens_out: res?.usage?.tokens_out ?? null,
|
|
255
297
|
};
|
package/src/review/fixloop.mjs
CHANGED
|
@@ -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
|
-
?
|
|
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)
|
|
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;
|