@codedology/code-forge 0.14.1 → 0.15.1
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 +29 -0
- package/README.md +1 -1
- package/docs/reference/blocks.json +495 -1
- package/docs/reference/config.md +7 -0
- package/package.json +1 -1
- package/schema/code-forge.schema.json +22 -0
- package/scripts/first-use.mjs +11 -5
- package/skill/SKILL.md +4 -0
- package/skill/references/autopilot.md +2 -0
- package/skill/references/code.md +28 -2
- package/skill/references/review.md +10 -2
- package/skill/references/security.md +2 -0
- package/skill/scripts/forge +1 -1
- package/skill/templates/coder-brief.md +3 -1
- package/src/autopilot/scopes.mjs +2 -1
- package/src/cli/block.mjs +271 -50
- package/src/cli/gates.mjs +36 -6
- package/src/cli/ledger.mjs +21 -2
- package/src/cli/report.mjs +14 -0
- package/src/cli/review-file.mjs +56 -16
- package/src/cli/run.mjs +44 -7
- package/src/cli/status.mjs +7 -3
- package/src/config/validate.mjs +13 -1
- package/src/gates/acceptance.mjs +7 -3
- package/src/gates/expand.mjs +25 -2
- package/src/gates/run.mjs +38 -8
- package/src/ledger/report.mjs +29 -7
- package/src/review/accept-hit.mjs +22 -4
- package/src/review/engine.mjs +30 -10
- package/src/review/finding.schema.json +2 -1
- package/src/review/fixloop.mjs +79 -14
- package/src/review/gate-check.mjs +33 -7
- package/src/review/lenses/recheck.md +3 -0
- package/src/review/packet.mjs +26 -0
- package/src/state/block.mjs +196 -22
- package/src/state/run.mjs +35 -8
- package/src/util/forbidden.mjs +230 -6
- package/src/util/reaper.mjs +3 -3
- package/src/worker/engine.mjs +74 -6
- package/src/worker/loop.mjs +19 -10
- package/src/worker/queue.mjs +38 -8
package/src/gates/run.mjs
CHANGED
|
@@ -9,12 +9,18 @@
|
|
|
9
9
|
import { exec } from '../util/exec.mjs';
|
|
10
10
|
import { registerSecret } from '../util/redact.mjs';
|
|
11
11
|
import { CHANGED_FILES_TOKEN } from '../util/gate-token.mjs';
|
|
12
|
-
import { NO_BLOCK_SCOPE, envOf, expandArgv, maskableEnvValues, scopeFields } from './expand.mjs';
|
|
12
|
+
import { NO_BLOCK_SCOPE, TEST_FILES_TOKEN, changedTestFiles, envOf, expandArgv, maskableEnvValues, scopeFields } from './expand.mjs';
|
|
13
13
|
|
|
14
14
|
/** @typedef {import('./detect.mjs').Gate} Gate */
|
|
15
15
|
/** @typedef {import('./detect.mjs').GateCommand} GateCommand */
|
|
16
16
|
/** @typedef {import('./detect.mjs').DetectedGates} DetectedGates */
|
|
17
17
|
/** @typedef {import('./expand.mjs').TestScope} TestScope */
|
|
18
|
+
/** @typedef {{n: number, files: string[], failed: boolean}} Repeats */
|
|
19
|
+
/**
|
|
20
|
+
* Internal block context carried with the gate plan through the proof lock, so repeats run
|
|
21
|
+
* inside the same slot as the initial commands.
|
|
22
|
+
* @typedef {{n: number, base: string, owned: string[], kinds?: Record<string, string>, onSkip?: (line: string) => void}} RepeatNewTests
|
|
23
|
+
*/
|
|
18
24
|
|
|
19
25
|
/**
|
|
20
26
|
* @typedef {object} GateResult
|
|
@@ -28,7 +34,8 @@ import { NO_BLOCK_SCOPE, envOf, expandArgv, maskableEnvValues, scopeFields } fro
|
|
|
28
34
|
* @property {string} stderr
|
|
29
35
|
* @property {'related'|'full'|'changed'} [scope] - only on a gate with a scope token.
|
|
30
36
|
* @property {number} [test_count] - B60: with `scope: related`, how many test files it ran.
|
|
31
|
-
* @property {'threshold'|'no_tests'|'no_baseline'|'unpaired'|'no_changes'} [reason] -
|
|
37
|
+
* @property {'threshold'|'no_tests'|'no_baseline'|'unpaired'|'no_changes'|'flaky-test'} [reason] - fallback or repeat failure.
|
|
38
|
+
* @property {Repeats} [repeats]
|
|
32
39
|
* @property {string[]} [env_keys] - B60: only on a gate given with a non-empty `env`: the variable
|
|
33
40
|
* NAMES, sorted (the values are never in a result).
|
|
34
41
|
*/
|
|
@@ -53,16 +60,16 @@ function normalizeCommand(command) {
|
|
|
53
60
|
* item that is exactly `{test_files}` expands to its files, or is dropped for a full run. Left out,
|
|
54
61
|
* no test file is known (`scope: full, reason: no_tests`). A gate with `env` runs with those
|
|
55
62
|
* variables added to the inherited environment; a gate with neither runs exactly as before B60.
|
|
56
|
-
* @param {{test?: Gate, lint?: Gate, types?: Gate, format?: Gate}} gates
|
|
63
|
+
* @param {{test?: Gate, lint?: Gate, types?: Gate, format?: Gate, repeatNewTests?: RepeatNewTests, lightTestFiles?: string[]}} gates
|
|
57
64
|
* @param {{cwd: string, timeoutMs?: number, scope?: TestScope | import('./expand.mjs').ChangedScope}} opts
|
|
58
|
-
* @returns {Promise<{results: GateResult[], allOk: boolean}>}
|
|
65
|
+
* @returns {Promise<{results: GateResult[], allOk: boolean, repeats?: Repeats}>}
|
|
59
66
|
*/
|
|
60
67
|
export async function runGates(gates, opts) {
|
|
61
68
|
if (!gates || typeof gates !== 'object') {
|
|
62
69
|
throw new TypeError('runGates: gates must be an object with test/lint/types/format');
|
|
63
70
|
}
|
|
64
71
|
const { cwd, timeoutMs = 300000 } = opts ?? {};
|
|
65
|
-
const scope = opts?.scope ?? NO_BLOCK_SCOPE;
|
|
72
|
+
const scope = gates.lightTestFiles ? { scope: /** @type {const} */ ('related'), files: gates.lightTestFiles } : opts?.scope ?? NO_BLOCK_SCOPE;
|
|
66
73
|
if (typeof cwd !== 'string' || cwd.length === 0) {
|
|
67
74
|
throw new TypeError('runGates: opts.cwd must be a non-empty string');
|
|
68
75
|
}
|
|
@@ -81,7 +88,7 @@ export async function runGates(gates, opts) {
|
|
|
81
88
|
if (normalized.argv[0] === CHANGED_FILES_TOKEN) throw new TypeError(`gates.${gate}: '{changed_files}' cannot be the command (argv[0])`);
|
|
82
89
|
const { argv, scoped } = expandArgv(normalized.argv, scope);
|
|
83
90
|
if (argv.length === 0 && normalized.argv.length > 0) throw new TypeError(`gates.${gate}: the argv is empty once ${normalized.argv.map((item) => `'${item}'`).join(' and ')} is dropped for the full suite — put the test command before it`);
|
|
84
|
-
return { gate, command: { argv, scoped, failOnStdout: normalized.failOnStdout, env: normalized.env } };
|
|
91
|
+
return { gate, command: { argv, originalArgv: normalized.argv, scoped, failOnStdout: normalized.failOnStdout, env: normalized.env } };
|
|
85
92
|
});
|
|
86
93
|
/** @type {GateResult[]} */
|
|
87
94
|
const results = [];
|
|
@@ -95,7 +102,7 @@ export async function runGates(gates, opts) {
|
|
|
95
102
|
// next, so a red gate is reported red without swallowing the gates that follow it.
|
|
96
103
|
const res = await exec(argv, env ? { cwd, timeoutMs, env: { ...process.env, ...env } } : { cwd, timeoutMs });
|
|
97
104
|
const ok = res.result === 'ok' && (!failOnStdout || res.stdout.trim().length === 0);
|
|
98
|
-
|
|
105
|
+
const result = /** @type {GateResult} */ ({
|
|
99
106
|
gate,
|
|
100
107
|
argv,
|
|
101
108
|
skipped: false,
|
|
@@ -106,6 +113,29 @@ export async function runGates(gates, opts) {
|
|
|
106
113
|
...(scoped ? scopeFields(scope) : {}),
|
|
107
114
|
...(env ? { env_keys: Object.keys(env).sort() } : {}),
|
|
108
115
|
});
|
|
116
|
+
const repeat = gates.repeatNewTests;
|
|
117
|
+
if (gate === 'test' && ok && repeat) {
|
|
118
|
+
if (!command.originalArgv.includes(TEST_FILES_TOKEN)) {
|
|
119
|
+
repeat.onSkip?.('repeat_new_tests: skipped (gates.test has no {test_files})');
|
|
120
|
+
} else {
|
|
121
|
+
const files = (await changedTestFiles({ cwd, ...repeat })).filter((file) => !gates.lightTestFiles || gates.lightTestFiles.includes(file));
|
|
122
|
+
if (files.length > 0) {
|
|
123
|
+
result.repeats = { n: repeat.n, files, failed: false };
|
|
124
|
+
const repeatArgv = expandArgv(command.originalArgv, { scope: 'related', files }).argv;
|
|
125
|
+
for (let i = 0; i < repeat.n; i++) {
|
|
126
|
+
const repeated = await exec(repeatArgv, env ? { cwd, timeoutMs, env: { ...process.env, ...env } } : { cwd, timeoutMs });
|
|
127
|
+
const passed = repeated.result === 'ok' && (!failOnStdout || repeated.stdout.trim().length === 0);
|
|
128
|
+
// Keep the first failed run's output, even when a later repeat passes.
|
|
129
|
+
if (!passed && !result.repeats.failed) {
|
|
130
|
+
result.repeats.failed = true;
|
|
131
|
+
Object.assign(result, { ok: false, reason: 'flaky-test', argv: repeatArgv, code: repeated.code, stdout: repeated.stdout, stderr: repeated.stderr });
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
results.push(result);
|
|
109
138
|
}
|
|
110
|
-
|
|
139
|
+
const repeats = results.find((r) => r.repeats)?.repeats;
|
|
140
|
+
return { results, allOk: results.every((r) => r.ok), ...(repeats ? { repeats } : {}) };
|
|
111
141
|
}
|
package/src/ledger/report.mjs
CHANGED
|
@@ -6,7 +6,8 @@
|
|
|
6
6
|
* answers are "never acted on" — must not move a single aggregate).
|
|
7
7
|
*
|
|
8
8
|
* Row conventions read here: `dispatch` {block,level,lane,lines}; `block.close`
|
|
9
|
-
* {block,status,lines_actual} —
|
|
9
|
+
* {block,status,lines_actual} — a `block.close` row marks the block completed unless followed by
|
|
10
|
+
* `block.awaiting_human_check`; `block.human_pass` completes the waiting block (a stop is
|
|
10
11
|
* `block.stop`; B19); `review.plan` {block,tier,depth_unconstrained,depth_chosen,
|
|
11
12
|
* degrade_step}; `session` {block,role,provider,level,tokens_in,tokens_out,usd,usd_unknown?} — one row per
|
|
12
13
|
* coder/reviewer/judge/s2/author/facts spend (plus `ledger add coder` manual rows and
|
|
@@ -81,7 +82,7 @@ export function buildRunModes(rows) {
|
|
|
81
82
|
|
|
82
83
|
/**
|
|
83
84
|
* Cost per block — completed AND still open (B33: a running total while the block is open):
|
|
84
|
-
* review $ / coder $ / S1+S2 $ / facts $ / proof ms, `completed` (
|
|
85
|
+
* review $ / coder $ / S1+S2 $ / facts $ / proof ms, `completed` (code close with no pending human check) and
|
|
85
86
|
* `unknownUsdSessions` (spend rows whose price is unknown, counted 0 in the totals). Grouped by
|
|
86
87
|
* run + block, so a block id reused in a second run is its own entry; a row without a run joins
|
|
87
88
|
* 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
|
|
@@ -92,6 +93,7 @@ function costPerBlock(rows) {
|
|
|
92
93
|
const byBlock = new Map();
|
|
93
94
|
/** @type {Set<string>} run+block keys with at least one spend row */
|
|
94
95
|
const spent = new Set();
|
|
96
|
+
const closedKeys = new Set();
|
|
95
97
|
// never split one block: a row without a run joins its block's entry when that block id appears
|
|
96
98
|
// in exactly one run; only a block seen in several runs leaves such a row in a `run: null` entry
|
|
97
99
|
/** @type {Map<string, Set<string>>} */
|
|
@@ -133,7 +135,9 @@ function costPerBlock(rows) {
|
|
|
133
135
|
e.level = row.level ?? e.level;
|
|
134
136
|
e.lane = row.lane ?? e.lane;
|
|
135
137
|
}
|
|
136
|
-
if (row.event === 'block.close') e.completed = true;
|
|
138
|
+
if (row.event === 'block.close') { e.completed = true; closedKeys.add(key); }
|
|
139
|
+
if (row.event === 'block.awaiting_human_check') e.completed = false;
|
|
140
|
+
if (row.event === 'block.human_pass') e.completed = true;
|
|
137
141
|
if (isSpendRow(row)) {
|
|
138
142
|
spent.add(key);
|
|
139
143
|
const cost = sessionCostUsd(row);
|
|
@@ -150,7 +154,7 @@ function costPerBlock(rows) {
|
|
|
150
154
|
}
|
|
151
155
|
if (row.event === 'proof' && typeof row.duration_ms === 'number') e.proofTimeMs += row.duration_ms;
|
|
152
156
|
}
|
|
153
|
-
const out = [...byBlock.entries()].filter(([key, e]) => e.completed || spent.has(key)).map(([, e]) => e);
|
|
157
|
+
const out = [...byBlock.entries()].filter(([key, e]) => e.completed || spent.has(key) || closedKeys.has(key)).map(([, e]) => e);
|
|
154
158
|
for (const e of out) {
|
|
155
159
|
for (const k of ['reviewUsd', 'coderUsd', 's1s2Usd', 'factsUsd', 'otherUsd', 'totalUsd']) e[k] = Math.round(e[k] * 10000) / 10000;
|
|
156
160
|
}
|
|
@@ -244,9 +248,17 @@ function proofTimePerBlock(rows) {
|
|
|
244
248
|
|
|
245
249
|
function forecastVsActualLines(rows) {
|
|
246
250
|
const forecasts = new Map(rows.filter((r) => r.event === 'dispatch').map((r) => [r.block, r.lines]));
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
251
|
+
const key = (r) => JSON.stringify([r.run ?? null, r.block]);
|
|
252
|
+
const gates = new Map(rows.filter((r) => r.event === 'gate.done').map((r) => [key(r), r]));
|
|
253
|
+
const closed = rows.filter((r) => r.event === 'block.close' && typeof r.lines_actual === 'number');
|
|
254
|
+
const closedKeys = new Set(closed.map(key));
|
|
255
|
+
const repeatCount = (r) => gates.get(key(r))?.repeats?.n;
|
|
256
|
+
return [
|
|
257
|
+
...closed.map((r) => ({ block: r.block, forecast: forecasts.get(r.block) ?? null, actual: r.lines_actual,
|
|
258
|
+
...(repeatCount(r) !== undefined ? { repeats: repeatCount(r) } : {}) })),
|
|
259
|
+
...[...gates.values()].filter((r) => r.repeats && !closedKeys.has(key(r)))
|
|
260
|
+
.map((r) => ({ block: r.block, forecast: forecasts.get(r.block) ?? null, actual: null, repeats: r.repeats.n })),
|
|
261
|
+
];
|
|
250
262
|
}
|
|
251
263
|
|
|
252
264
|
const blocksStoppedAtL3 = (rows) => rows.filter((r) => r.event === 'block.stop' && r.level === 'L3').map((r) => r.block);
|
|
@@ -317,5 +329,15 @@ export function buildReport(rows) {
|
|
|
317
329
|
/** @type {Record<string, any>} */
|
|
318
330
|
const sections = {};
|
|
319
331
|
for (const name of SECTION_NAMES) sections[name] = builders[name](acted);
|
|
332
|
+
// Preserve the existing report shape for projects that have no human-check blocks.
|
|
333
|
+
if (acted.some((r) => r.event === 'block.awaiting_human_check')) {
|
|
334
|
+
const waiting = new Map();
|
|
335
|
+
for (const row of acted) {
|
|
336
|
+
const key = `${row.run ?? ''}\u0000${row.block}`;
|
|
337
|
+
if (row.event === 'block.awaiting_human_check') waiting.set(key, row.block);
|
|
338
|
+
if (['block.human_pass', 'block.stop', 'dispatch'].includes(row.event)) waiting.delete(key);
|
|
339
|
+
}
|
|
340
|
+
sections.awaiting_human_check = [...new Set(waiting.values())].sort();
|
|
341
|
+
}
|
|
320
342
|
return { sections };
|
|
321
343
|
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
/** Human acceptance of
|
|
1
|
+
/** Human acceptance of false transcript hits, bound to each rule and exact line bytes. */
|
|
2
2
|
import { createHash } from 'node:crypto';
|
|
3
3
|
import { readFile } from 'node:fs/promises';
|
|
4
4
|
import { StateError } from '../state/paths.mjs';
|
|
@@ -27,7 +27,7 @@ export async function prepareAcceptHit({ runId, id, flags, positionals, extraTok
|
|
|
27
27
|
// eslint-disable-next-line no-control-regex
|
|
28
28
|
if (typeof value === 'string' && /[\x00-\x1f\x7f]/.test(value)) throw new StateError('usage', `${name === 'block' ? 'block id' : `--${name}`} must not contain control characters`);
|
|
29
29
|
}
|
|
30
|
-
for (const name of ['rule', 'transcript', 'reason']) {
|
|
30
|
+
for (const name of [...(flags.rule !== undefined ? ['rule'] : []), 'transcript', 'reason']) {
|
|
31
31
|
if (typeof flags[name] !== 'string' || flags[name].trim().length === 0) throw new StateError('usage', `--${name} is required`);
|
|
32
32
|
}
|
|
33
33
|
if (typeof flags.line !== 'string' || !/^-?\d+$/.test(flags.line) || !Number.isSafeInteger(Number(flags.line))) throw new StateError('usage', '--line must be an integer');
|
|
@@ -42,8 +42,26 @@ export async function prepareAcceptHit({ runId, id, flags, positionals, extraTok
|
|
|
42
42
|
throw noSuchHit();
|
|
43
43
|
}
|
|
44
44
|
const lines = text.split('\n');
|
|
45
|
-
|
|
46
|
-
|
|
45
|
+
const hits = scanTranscript(text, mergeForbidden(extraTokens));
|
|
46
|
+
if (line < 1 || line > lines.length || !hits.some((hit) => hit.line === line && (flags.rule === undefined || hit.id === flags.rule))) throw noSuchHit();
|
|
47
|
+
const selected = new Set([line]);
|
|
48
|
+
if (flags.rule === undefined) {
|
|
49
|
+
// Pair only recognized Codex JSON logs; arbitrary JSON-looking plain text has no item pair.
|
|
50
|
+
try {
|
|
51
|
+
const events = lines.map((s) => s.trim() ? JSON.parse(s) : undefined);
|
|
52
|
+
const nonEmpty = events.filter((event) => event !== undefined);
|
|
53
|
+
const itemId = events[line - 1]?.item?.id;
|
|
54
|
+
if (nonEmpty[0]?.type === 'thread.started' && nonEmpty.every((event) => event !== null && typeof event === 'object' && !Array.isArray(event) && typeof event.type === 'string') &&
|
|
55
|
+
events[line - 1]?.type?.startsWith('item.') && typeof itemId === 'string') {
|
|
56
|
+
events.forEach((event, index) => {
|
|
57
|
+
if (event?.type === 'item.completed' && event.item?.id === itemId) selected.add(index + 1);
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
} catch { /* plain text: accept only the requested line */ }
|
|
61
|
+
}
|
|
62
|
+
const rows = hits.filter((hit) => selected.has(hit.line) && (flags.rule === undefined || hit.id === flags.rule))
|
|
63
|
+
.map((hit) => ({ block: id, rule: hit.id, line: hit.line, line_sha256: lineHash(lines[hit.line - 1]), reason: /** @type {string} */ (flags.reason.trim()), by: 'human' }));
|
|
64
|
+
return { record, rows };
|
|
47
65
|
}
|
|
48
66
|
|
|
49
67
|
/**
|
package/src/review/engine.mjs
CHANGED
|
@@ -29,7 +29,7 @@
|
|
|
29
29
|
* The summary then carries `schema_fallback: {level, provider, model}` (`level`:
|
|
30
30
|
* `review.second_levels.<level>`), the second level's own provider/model, and `tokens_in` /
|
|
31
31
|
* `tokens_out` summed over both sessions;
|
|
32
|
-
* 7. a
|
|
32
|
+
* 7. a quick packet over budget first steps up to one full L2 session, including Markdown.
|
|
33
33
|
* A diff alone still over the packet budget is `split_required` (with `tokens_in` and `budget`) —
|
|
34
34
|
* except a Markdown file (B54), which is reviewed SECTION BY SECTION (`sections.mjs`): every
|
|
35
35
|
* section packet runs the whole plan above (its own sessions, judge and stub guard, its own
|
|
@@ -65,7 +65,7 @@ import { blockKind, isDocsKind } from '../decide/escalation.mjs';
|
|
|
65
65
|
import { fallbackRisk } from '../decide/fallback-rules.mjs';
|
|
66
66
|
import { isMigrationPath, isPolicyPath } from '../decide/lane-facts.mjs';
|
|
67
67
|
import { tierFor } from '../proof/tiers.mjs';
|
|
68
|
-
import { assembleJudgePacket, assemblePacket, attachFiles, budgetFor, diffOnlyTokens, isSecretLike, readFileDiff } from './packet.mjs';
|
|
68
|
+
import { assembleJudgePacket, assembleLightPacket, assemblePacket, attachFiles, budgetFor, diffOnlyTokens, isSecretLike, readFileDiff } from './packet.mjs';
|
|
69
69
|
import { movedText, movesFor, peerDiffReader } from './moved.mjs';
|
|
70
70
|
import { deletionNote, renderDeletionNote } from './deletion.mjs';
|
|
71
71
|
import { testQualityNote } from './test-quality.mjs';
|
|
@@ -158,17 +158,17 @@ export function planSessions({ risk, cfg, kind = 'code' }) {
|
|
|
158
158
|
|
|
159
159
|
/**
|
|
160
160
|
* The rules risk (§3.5 fallback; S1 `risk` without Jev): a `proof.tiers.high.paths` match ⇒ 3,
|
|
161
|
-
* a migration/policy/middleware path ⇒ 2, more than 200
|
|
162
|
-
* @param {{file: string, plusCount: number, cfg: Record<string, any>}} opts
|
|
161
|
+
* a migration/policy/middleware path ⇒ 2, more than 200 changed lines ⇒ 1, else 0.
|
|
162
|
+
* @param {{file: string, plusCount: number, minusCount?: number, cfg: Record<string, any>}} opts
|
|
163
163
|
* @returns {number}
|
|
164
164
|
*/
|
|
165
|
-
export function rulesRisk({ file, plusCount, cfg }) {
|
|
165
|
+
export function rulesRisk({ file, plusCount, minusCount = 0, cfg }) {
|
|
166
166
|
const highPaths = cfg?.proof?.tiers?.high?.paths;
|
|
167
167
|
const pathFloorHit = tierFor({ file, risk: 0, highPaths: Array.isArray(highPaths) ? highPaths : [] }).reason === 'path';
|
|
168
168
|
return fallbackRisk({
|
|
169
169
|
pathFloorHit,
|
|
170
170
|
filesChanged: 1,
|
|
171
|
-
linesAdded: plusCount,
|
|
171
|
+
linesAdded: plusCount + minusCount,
|
|
172
172
|
touchesMigration: isMigrationPath(file),
|
|
173
173
|
touchesPolicyOrMiddleware: isPolicyPath(file),
|
|
174
174
|
});
|
|
@@ -262,7 +262,7 @@ export function pairedContext({ repoRoot, file, peers = [], ownedFiles = [], cha
|
|
|
262
262
|
export async function reviewFile(input, deps) {
|
|
263
263
|
const { repoRoot, cfg, workDir } = input;
|
|
264
264
|
const diff = await readFileDiff({ repoRoot, file: input.file, base: input.base ?? null });
|
|
265
|
-
const risk = input.risk ?? rulesRisk({ file: diff.file, plusCount: diff.plusCount, cfg });
|
|
265
|
+
const risk = input.risk ?? rulesRisk({ file: diff.file, plusCount: diff.plusCount, minusCount: diff.minusCount, cfg });
|
|
266
266
|
// no recorded kind ⇒ the reviewed file's own kind (a lone `.md` is a docs review), never a blind `code`
|
|
267
267
|
const kind = typeof input.kind === 'string' ? input.kind : blockKind({ owned: [diff.file] });
|
|
268
268
|
let plan = planSessions({ risk, cfg, kind });
|
|
@@ -363,13 +363,13 @@ export async function reviewFile(input, deps) {
|
|
|
363
363
|
let split = /** @type {{status: 'split_required', tokensIn: number, budget: number} | undefined} */ (packets.find((p) => p.status === 'split_required'));
|
|
364
364
|
/** @type {Record<string, any>} */
|
|
365
365
|
let stepUp = {};
|
|
366
|
-
if (split && plan.depth === 'quick'
|
|
366
|
+
if (split && plan.depth === 'quick') {
|
|
367
367
|
const full = planSessions({ risk: 1, cfg, kind });
|
|
368
368
|
// A configured consensus/refusal is not the single full session this step permits.
|
|
369
369
|
if (full.depth === 'full') {
|
|
370
370
|
stepUp = { step_up: { from: 'quick', tokens_in: split.tokensIn, budget: split.budget } };
|
|
371
371
|
plan = full;
|
|
372
|
-
head = { ...head, engine: plan.mode, depth: plan.depth };
|
|
372
|
+
head = { ...head, engine: plan.mode, depth: plan.depth, ...stepUp };
|
|
373
373
|
reviewers = plan.sessions.filter((s) => s.role === 'reviewer');
|
|
374
374
|
judgeSpec = plan.sessions.find((s) => s.role === 'judge');
|
|
375
375
|
packets = packetsFor(diff);
|
|
@@ -431,7 +431,7 @@ export async function reviewFile(input, deps) {
|
|
|
431
431
|
const summaries = [];
|
|
432
432
|
let approved = true;
|
|
433
433
|
for (const [i, section] of packed.sections.entries()) {
|
|
434
|
-
const part = await reviewPart(section.diff, sectionPackets[i], { section: section.index, sections: packed.sections.length }, section.keys);
|
|
434
|
+
const part = await reviewPart(section.diff, sectionPackets[i], { ...stepUp, section: section.index, sections: packed.sections.length }, section.keys);
|
|
435
435
|
sessions.push(...part.sessions.map((s) => ({ ...s, section: section.index })));
|
|
436
436
|
if (part.status !== 'reviewed') return { status: part.status, reason: part.reason, approved: false, ...head, sections, sessions };
|
|
437
437
|
approved = approved && part.approved;
|
|
@@ -441,6 +441,26 @@ export async function reviewFile(input, deps) {
|
|
|
441
441
|
return { status: 'reviewed', approved, ...head, summary: summaries.join('\n'), findings, sessions, sections };
|
|
442
442
|
}
|
|
443
443
|
|
|
444
|
+
/** A light round has one reviewer and no recheck/triage loop.
|
|
445
|
+
* @param {{repoRoot: string, files: string[], base: string | null, cfg: Record<string, any>, workDir: string, rulesDigest?: string, factsExcerpt?: string}} input
|
|
446
|
+
* @param {ReviewDeps} deps
|
|
447
|
+
* @returns {Promise<import('../worker/engine.mjs').ReviewOutcome>}
|
|
448
|
+
*/
|
|
449
|
+
export async function reviewLightBatch(input, deps) {
|
|
450
|
+
const diffs = await Promise.all(input.files.map((file) => readFileDiff({ repoRoot: input.repoRoot, file, base: input.base })));
|
|
451
|
+
const packet = assembleLightPacket({ ...input, diffs });
|
|
452
|
+
const head = { engine: 'adaptive', depth: 'full' };
|
|
453
|
+
if (packet.status !== 'ok') return { ...head, status: 'split_required', approved: false, tokens_in: packet.tokensIn, budget: packet.budget, sessions: [] };
|
|
454
|
+
mkdirSync(input.workDir, { recursive: true, mode: 0o700 });
|
|
455
|
+
const final = await runOne({ lens: 'full', role: 'reviewer', level: 'L2', slot: null }, packet.text, packet,
|
|
456
|
+
{ deps, workDir: input.workDir, min: minTokensOut(input.cfg), cfg: input.cfg, schemaFallback: false });
|
|
457
|
+
const sessions = [publicSummary(final)];
|
|
458
|
+
if (final.status !== 'ok') return { ...head, status: 'unavailable', reason: final.reason, approved: false, sessions };
|
|
459
|
+
if (final.review.findings.some((f) => !input.files.includes(f.file))) return { ...head, status: 'unavailable', reason: 'finding-file', approved: false, sessions };
|
|
460
|
+
if (final.review.needs_file?.length > 0) return { ...head, status: 'unavailable', reason: 'needs-file', approved: false, sessions };
|
|
461
|
+
return { ...head, status: 'reviewed', approved: final.review.passed === true, findings: final.review.findings, sessions };
|
|
462
|
+
}
|
|
463
|
+
|
|
444
464
|
/**
|
|
445
465
|
* One session: packet file (0600, removed after), spawn, stub guard — and, on a `schema` miss or
|
|
446
466
|
* (B91) a timed-out attempt, the B55 second-level try (`schema-fallback.mjs`) on the same packet
|
|
@@ -38,7 +38,8 @@
|
|
|
38
38
|
"properties": {
|
|
39
39
|
"id": { "type": "string" },
|
|
40
40
|
"resolved": { "type": "boolean" },
|
|
41
|
-
"why": { "type": "string", "maxLength": 200 }
|
|
41
|
+
"why": { "type": "string", "maxLength": 200 },
|
|
42
|
+
"current_lines": { "type": "string", "maxLength": 300 }
|
|
42
43
|
}
|
|
43
44
|
}
|
|
44
45
|
},
|
package/src/review/fixloop.mjs
CHANGED
|
@@ -91,7 +91,7 @@ import { verifyRow } from '../state/signer.mjs';
|
|
|
91
91
|
import { decisionProblem, grantCoverProblem } from './gate-check.mjs';
|
|
92
92
|
import { contentHash, gitChildEnv } from '../worker/ticket.mjs';
|
|
93
93
|
import { parseDiff } from './context.mjs';
|
|
94
|
-
import { assemblePacket, assertPacketPath, diffOnlyTokens, readFileDiff } from './packet.mjs';
|
|
94
|
+
import { assemblePacket, assertPacketPath, budgetFor, diffOnlyTokens, readFileDiff } from './packet.mjs';
|
|
95
95
|
import { movedText, movesFor, peerDiffReader } from './moved.mjs';
|
|
96
96
|
import { pairedContext } from './engine.mjs';
|
|
97
97
|
import { isMarkdownFile, packSections, sectionFindings, sectionGroups, sectionOf } from './sections.mjs';
|
|
@@ -101,6 +101,7 @@ import { spawnWithTimeoutRetry } from './session-retry.mjs';
|
|
|
101
101
|
import { afterSchemaMiss, logSecondLevelMiss, packetHash } from './schema-fallback.mjs';
|
|
102
102
|
|
|
103
103
|
/** @typedef {import('./triage.mjs').Finding} Finding */
|
|
104
|
+
/** @typedef {Finding & {repeated?: boolean}} LoopFinding */
|
|
104
105
|
/** @typedef {import('./context.mjs').Hunk} Hunk */
|
|
105
106
|
|
|
106
107
|
/**
|
|
@@ -121,12 +122,15 @@ import { afterSchemaMiss, logSecondLevelMiss, packetHash } from './schema-fallba
|
|
|
121
122
|
* @property {number} round - rounds counted so far (patch_check rounds excluded).
|
|
122
123
|
* @property {number} rounds_at_level
|
|
123
124
|
* @property {number} [warn_rounds_at_level] - heavy rounds at the current level (rule 2c, B34); absent in a state saved before B34 ⇒ 0.
|
|
124
|
-
* @property {
|
|
125
|
+
* @property {LoopFinding[]} open - the open `fix_now` set.
|
|
125
126
|
* @property {Finding[]} late
|
|
127
|
+
* @property {string[]} [finding_history] - claim/evidence keys from earlier rounds of this file.
|
|
128
|
+
* @property {string[]} [finding_ids] - ids seen in earlier rounds, including closed findings.
|
|
126
129
|
* @property {string | null} reviewed_content - the content the last counted round reviewed.
|
|
127
130
|
* @property {boolean} l3_rung_used - the block's L3 rung (rule 5 or 6) was used — once per block.
|
|
128
131
|
* @property {'open' | 'complete' | 'stopped'} status
|
|
129
132
|
* @property {string} [stop_content_hash] - bytes reviewed by a stop (absent in legacy states).
|
|
133
|
+
* @property {string} [stop_lens] - the lens whose packet stopped before a round.
|
|
130
134
|
* @property {NextStep | null} next
|
|
131
135
|
* @property {{context_mode: string, context_lines: number} | null} [last_packet] - the last recheck round's context: one packet's, or over a Markdown recheck's section packets the summed lines and the one mode they share, else `mixed` (B54).
|
|
132
136
|
* @property {'full' | 'recheck' | 'patch_check' | null} [pending_kind] - the round to re-run after a `retry`.
|
|
@@ -262,11 +266,24 @@ export function extraRoundsFor(rows, { runId, block, file, key }) {
|
|
|
262
266
|
/**
|
|
263
267
|
* B47: reopen a file stopped at `review_cap` when its raised cap leaves a round: the state goes
|
|
264
268
|
* back to `open` with `next: fix` at its level (`trigger: autopilot_extra_round`); the next round
|
|
265
|
-
* is a recheck.
|
|
269
|
+
* is a recheck. B132 also resumes a pre-round split stop when its packet budget changed,
|
|
270
|
+
* preserving the counted rounds and findings. Any other state is left as it is.
|
|
266
271
|
* @param {FileState} state @param {number} extraRounds @param {Record<string, any> | undefined} cfg
|
|
267
272
|
* @returns {boolean} whether the state was reopened.
|
|
268
273
|
*/
|
|
269
274
|
export function reopenForExtraRound(state, extraRounds, cfg) {
|
|
275
|
+
// Only a recheck resumes as a fix; full and patch_check stops keep their round kind.
|
|
276
|
+
// Its packet is measured against full_in, so other lens budgets cannot reopen it.
|
|
277
|
+
if (state.status === 'stopped' && state.next?.reason === 'split_required' && state.stop_lens === 'recheck' &&
|
|
278
|
+
typeof state.next.budget === 'number' && Number.isFinite(state.next.budget) && state.next.budget > 0 &&
|
|
279
|
+
state.next.budget !== budgetFor(cfg, 'full_in')) {
|
|
280
|
+
state.status = 'open';
|
|
281
|
+
state.next = { action: 'fix', level: state.level, trigger: null };
|
|
282
|
+
state.pending_kind = null;
|
|
283
|
+
delete state.stop_content_hash;
|
|
284
|
+
delete state.stop_lens;
|
|
285
|
+
return true;
|
|
286
|
+
}
|
|
270
287
|
if (state.status !== 'stopped' || state.next?.reason !== 'review_cap') return false;
|
|
271
288
|
if (state.round >= roundCap(cfg, extraRounds)) return false;
|
|
272
289
|
state.status = 'open';
|
|
@@ -349,8 +366,18 @@ const OPEN_FINDINGS_HEAD = '## open findings\n';
|
|
|
349
366
|
/** @param {Finding} f @returns {string} the finding's line in a recheck packet's open-findings list. */
|
|
350
367
|
export const openFindingLine = (f) => `- ${f.id} (${f.severity}, lines ${f.line_start}-${f.line_end}): ${f.claim ?? ''}`;
|
|
351
368
|
|
|
369
|
+
/** @param {Finding} f @returns {string} compare BOTH fields, ignoring only surrounding whitespace. */
|
|
370
|
+
const findingKey = (f) => JSON.stringify([(f.claim ?? '').trim(), (f.evidence ?? '').trim()]);
|
|
371
|
+
|
|
372
|
+
/** @param {string} text @returns {string} */
|
|
373
|
+
const normaliseWhitespace = (text) => text.trim().replace(/\s+/g, ' ');
|
|
374
|
+
|
|
352
375
|
/** @param {ReadonlyArray<Finding>} open @returns {string} the open-findings list a recheck packet ends with. */
|
|
353
|
-
const openFindingsText = (open) =>
|
|
376
|
+
const openFindingsText = (open) => {
|
|
377
|
+
// Keep the final id/claim list stable for section readers; evidence is keyed by id just before it.
|
|
378
|
+
const evidence = open.length > 0 ? `## finding evidence\n${open.map((f) => `${f.id}: ${(f.evidence ?? '').trim().replace(/\s+/g, ' ').slice(0, 300)}`).join('\n')}\n` : '';
|
|
379
|
+
return `${evidence}${OPEN_FINDINGS_HEAD}${open.length > 0 ? open.map(openFindingLine).join('\n') : '(none)'}\n`;
|
|
380
|
+
};
|
|
354
381
|
|
|
355
382
|
/**
|
|
356
383
|
* The size a recheck packet is held to (B54): its diff-only `recheck` packet (the measure behind
|
|
@@ -631,10 +658,11 @@ async function stopBeforeRound(state, deps, kind, file, contentHash, packet) {
|
|
|
631
658
|
typeof packet.tokensIn === 'number' && typeof packet.budget === 'number'
|
|
632
659
|
? { tokens_in: packet.tokensIn, budget: packet.budget, ...(typeof packet.section === 'string' ? { section: packet.section } : {}) }
|
|
633
660
|
: {};
|
|
634
|
-
const row = { event: 'review.cap', file, round: state.round, reason, open: state.open.map((f) => f.id), ...size };
|
|
661
|
+
const row = { event: 'review.cap', file, round: state.round, reason, open: state.open.map((f) => f.id), ...size, lens: kind };
|
|
635
662
|
if (!(await mustNote(deps, row))) return retry(state, kind, 'ledger_write_failed');
|
|
636
663
|
state.status = 'stopped';
|
|
637
664
|
state.stop_content_hash = contentHash;
|
|
665
|
+
state.stop_lens = kind;
|
|
638
666
|
state.next = { action: 'stop', reason, ...size };
|
|
639
667
|
state.pending_kind = null;
|
|
640
668
|
return state;
|
|
@@ -661,6 +689,14 @@ export async function runRound(state, deps, opts = {}) {
|
|
|
661
689
|
let newInHunks = 0;
|
|
662
690
|
/** @type {Array<Record<string, any>>} */
|
|
663
691
|
const lateRows = [];
|
|
692
|
+
/** @type {Array<Record<string, any>>} */
|
|
693
|
+
const staleRows = [];
|
|
694
|
+
const history = new Set([...(state.finding_history ?? []), ...state.open.map(findingKey), ...state.late.map(findingKey)]);
|
|
695
|
+
const knownIds = new Set([...(state.finding_ids ?? []), ...state.open.map((f) => f.id), ...state.late.map((f) => f.id)]);
|
|
696
|
+
const roundFindings = new Set();
|
|
697
|
+
const roundIds = new Set();
|
|
698
|
+
/** @type {Set<string>} */
|
|
699
|
+
const repeated = new Set();
|
|
664
700
|
/** @type {number | null} */
|
|
665
701
|
let tokensIn = null;
|
|
666
702
|
/** @type {number | null} */
|
|
@@ -671,6 +707,10 @@ export async function runRound(state, deps, opts = {}) {
|
|
|
671
707
|
if (kind === 'full') {
|
|
672
708
|
const result = await deps.review();
|
|
673
709
|
if (result?.status !== 'reviewed') return retry(state, kind, result?.reason ?? result?.status ?? 'unavailable');
|
|
710
|
+
for (const finding of result.findings ?? []) {
|
|
711
|
+
roundFindings.add(findingKey(finding));
|
|
712
|
+
roundIds.add(finding.id);
|
|
713
|
+
}
|
|
674
714
|
const triaged = await triageFindings({ file: current.rel, findings: result.findings ?? [], fromJudge: cameFromJudge(result), cfg: deps.cfg, jev: deps.jev, rule: deps.rule, writeRow: deps.writeRow });
|
|
675
715
|
draft.open = triaged.fix_now;
|
|
676
716
|
newInHunks = triaged.fix_now.length;
|
|
@@ -727,6 +767,9 @@ export async function runRound(state, deps, opts = {}) {
|
|
|
727
767
|
}
|
|
728
768
|
/** @type {Set<Finding>} the pending findings a session that LISTED them resolved */
|
|
729
769
|
const resolved = new Set();
|
|
770
|
+
/** @type {Set<Finding>} */
|
|
771
|
+
const stale = new Set();
|
|
772
|
+
const currentText = normaliseWhitespace(current.content);
|
|
730
773
|
/** @type {Finding[]} */
|
|
731
774
|
const inside = [];
|
|
732
775
|
/** @type {Finding[]} */
|
|
@@ -742,11 +785,30 @@ export async function runRound(state, deps, opts = {}) {
|
|
|
742
785
|
return retry(state, kind, session.verdict.reason ?? 'unavailable');
|
|
743
786
|
}
|
|
744
787
|
const review = /** @type {Record<string, any>} */ (session.verdict.review);
|
|
745
|
-
|
|
746
|
-
//
|
|
747
|
-
for (const f of part.open)
|
|
788
|
+
// Only this packet's findings can close here. Legacy answers without a quote stay
|
|
789
|
+
// open; a nonempty quote that no longer exists in the file proves the answer stale.
|
|
790
|
+
for (const f of part.open) {
|
|
791
|
+
const answer = (review.resolved ?? []).find((/** @type {any} */ r) => r.id === f.id);
|
|
792
|
+
if (answer?.resolved === true) {
|
|
793
|
+
resolved.add(f);
|
|
794
|
+
continue;
|
|
795
|
+
}
|
|
796
|
+
const quote = normaliseWhitespace(answer?.current_lines ?? '');
|
|
797
|
+
const reason = quote.length > 0 && !currentText.includes(quote) ? 'current_lines_not_found' : null;
|
|
798
|
+
if (reason) {
|
|
799
|
+
stale.add(f);
|
|
800
|
+
staleRows.push({ event: 'review.stale_finding', file: current.rel, round: state.round + 1, finding: f.id, reason });
|
|
801
|
+
}
|
|
802
|
+
}
|
|
748
803
|
const found = /** @type {Finding[]} */ (review.findings ?? []);
|
|
749
804
|
for (const f of part.index === null ? found : sectionFindings(found, part.index)) {
|
|
805
|
+
const key = findingKey(f);
|
|
806
|
+
roundFindings.add(key);
|
|
807
|
+
roundIds.add(f.id);
|
|
808
|
+
if (kind === 'recheck' && !knownIds.has(f.id) && history.has(key)) {
|
|
809
|
+
Object.assign(f, { repeated: true });
|
|
810
|
+
repeated.add(f.id);
|
|
811
|
+
}
|
|
750
812
|
if (insideHunks(f, diff.hunks)) {
|
|
751
813
|
inside.push(f);
|
|
752
814
|
continue;
|
|
@@ -756,8 +818,7 @@ export async function runRound(state, deps, opts = {}) {
|
|
|
756
818
|
if (s.late === 'block' && f.severity !== 'nit') blocking.push(f);
|
|
757
819
|
}
|
|
758
820
|
}
|
|
759
|
-
|
|
760
|
-
const stillOpen = pending.filter((f) => !resolved.has(f));
|
|
821
|
+
const stillOpen = pending.filter((f) => !resolved.has(f) && !stale.has(f));
|
|
761
822
|
closed += pending.length - stillOpen.length;
|
|
762
823
|
const triaged = await triageFindings({ file: current.rel, findings: inside, fromJudge: false, diffText: diff.diffText, cfg: deps.cfg, jev: deps.jev, rule: deps.rule, writeRow: deps.writeRow });
|
|
763
824
|
newInHunks = triaged.fix_now.length;
|
|
@@ -767,11 +828,12 @@ export async function runRound(state, deps, opts = {}) {
|
|
|
767
828
|
// A patch_check with no fix hunks closed nothing: B73 still stops it.
|
|
768
829
|
}
|
|
769
830
|
|
|
831
|
+
const unweighted = kind !== 'full' && (staleRows.length > 0 || repeated.size > 0 || draft.open.some((f) => f.repeated)) && draft.open.every((f) => f.repeated);
|
|
770
832
|
if (kind !== 'patch_check') {
|
|
771
833
|
draft.round += 1;
|
|
772
|
-
draft.rounds_at_level += 1;
|
|
834
|
+
if (!unweighted) draft.rounds_at_level += 1;
|
|
773
835
|
// THIS round's open set, after triage: only the warnings still open count
|
|
774
|
-
if (heavyRound(draft.open, s.warnThreshold)) draft.warn_rounds_at_level = (draft.warn_rounds_at_level ?? 0) + 1;
|
|
836
|
+
if (!unweighted && heavyRound(draft.open, s.warnThreshold)) draft.warn_rounds_at_level = (draft.warn_rounds_at_level ?? 0) + 1;
|
|
775
837
|
} else {
|
|
776
838
|
// the block continues at L2 after the rung with a fresh round counter (§3.6) — also here, not
|
|
777
839
|
// only in `converge`, because the worker runs the patch_check from its own ticket
|
|
@@ -779,6 +841,8 @@ export async function runRound(state, deps, opts = {}) {
|
|
|
779
841
|
draft.warn_rounds_at_level = 0;
|
|
780
842
|
}
|
|
781
843
|
draft.reviewed_content = current.content;
|
|
844
|
+
if (history.size > 0 || roundFindings.size > 0) draft.finding_history = [...new Set([...history, ...roundFindings])];
|
|
845
|
+
if (knownIds.size > 0 || roundIds.size > 0) draft.finding_ids = [...new Set([...knownIds, ...roundIds])];
|
|
782
846
|
draft.last_packet = packetInfo;
|
|
783
847
|
const roundRow = {
|
|
784
848
|
event: 'review.round',
|
|
@@ -794,6 +858,7 @@ export async function runRound(state, deps, opts = {}) {
|
|
|
794
858
|
tokens_in: tokensIn,
|
|
795
859
|
tokens_out: tokensOut,
|
|
796
860
|
...(packetInfo ? { context_mode: packetInfo.context_mode } : {}),
|
|
861
|
+
...(repeated.size > 0 ? { repeated: [...repeated] } : {}),
|
|
797
862
|
};
|
|
798
863
|
|
|
799
864
|
if (draft.open.length === 0) {
|
|
@@ -805,13 +870,13 @@ export async function runRound(state, deps, opts = {}) {
|
|
|
805
870
|
// closed one is fixed at L2 (B73). Closed = an id open before the round, not open after it.
|
|
806
871
|
const after = new Set(draft.open.map((f) => f.id));
|
|
807
872
|
const closedIds = new Set(state.open.map((f) => f.id).filter((id) => !after.has(id))).size;
|
|
808
|
-
draft.next = decideNext(draft, openBefore, s, kind, closedIds);
|
|
873
|
+
draft.next = unweighted && kind !== 'patch_check' ? { action: 'fix', level: draft.level, trigger: null } : decideNext(draft, openBefore, s, kind, closedIds);
|
|
809
874
|
if (draft.next.action === 'stop') {
|
|
810
875
|
draft.status = 'stopped';
|
|
811
876
|
draft.stop_content_hash = current.hash;
|
|
812
877
|
}
|
|
813
878
|
}
|
|
814
|
-
const rows = [...lateRows, roundRow];
|
|
879
|
+
const rows = [...staleRows, ...lateRows, roundRow];
|
|
815
880
|
if (draft.status === 'stopped') rows.push({ event: 'review.cap', file: current.rel, round: draft.round, reason: draft.next.reason, open: draft.open.map((f) => f.id) });
|
|
816
881
|
for (const row of rows) {
|
|
817
882
|
if (!(await mustNote(deps, row))) return retry(state, kind, 'ledger_write_failed');
|