@codedology/code-forge 0.14.0 → 0.15.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 +28 -0
- package/README.md +136 -438
- package/docs/README.md +1 -1
- package/docs/reference/blocks.json +420 -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 +2 -0
- package/skill/references/autopilot.md +2 -0
- package/skill/references/code.md +25 -1
- package/skill/references/review.md +10 -2
- package/skill/scripts/forge +1 -1
- package/src/autopilot/scopes.mjs +2 -1
- package/src/cli/block.mjs +235 -44
- 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/cli/upgrade.mjs +95 -15
- 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/install/link.mjs +12 -6
- 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 +22 -3
- package/src/review/lenses/recheck.md +3 -0
- package/src/review/packet.mjs +26 -0
- package/src/state/block.mjs +162 -20
- package/src/state/run.mjs +35 -8
- package/src/util/forbidden.mjs +14 -0
- package/src/util/reaper.mjs +3 -3
- package/src/worker/engine.mjs +60 -4
- package/src/worker/loop.mjs +19 -10
- package/src/worker/queue.mjs +5 -2
package/src/gates/expand.mjs
CHANGED
|
@@ -27,6 +27,7 @@ import os from 'node:os';
|
|
|
27
27
|
import path from 'node:path';
|
|
28
28
|
import { isExactEntry, ownsFile } from '../state/registry.mjs';
|
|
29
29
|
import { pairsFor } from '../review/paired.mjs';
|
|
30
|
+
import { computeFileSet } from './scope.mjs';
|
|
30
31
|
import { exec } from '../util/exec.mjs';
|
|
31
32
|
import { TEST_FILES_TOKEN, CHANGED_FILES_TOKEN } from '../util/gate-token.mjs';
|
|
32
33
|
|
|
@@ -66,6 +67,30 @@ export function isTestPath(p) {
|
|
|
66
67
|
return /\.(test|spec)\.[^/]+$/.test(name) || name.endsWith('Test.php') || name.endsWith('_test.go') || /^test_.*\.py$/.test(name);
|
|
67
68
|
}
|
|
68
69
|
|
|
70
|
+
/** The changed-test predicate, including Swift's conventional Tests suffix. @param {string} file */
|
|
71
|
+
export function isChangedTest(file) {
|
|
72
|
+
return isTestPath(file) || file.endsWith('Tests.swift');
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Existing block tests added or changed since its base, independent of the last green scope
|
|
77
|
+
* and its threshold. Deleted tests and unchanged source-paired tests are not repeat candidates.
|
|
78
|
+
* @param {{cwd: string, base: string, owned: string[], kinds?: Record<string, string>}} opts
|
|
79
|
+
* @returns {Promise<string[]>}
|
|
80
|
+
*/
|
|
81
|
+
export async function changedTestFiles({ cwd, base, owned, kinds }) {
|
|
82
|
+
const { all } = await computeFileSet({ cwd, base });
|
|
83
|
+
const files = all.filter((file) => ownsFile(owned, file, kinds) && isChangedTest(file));
|
|
84
|
+
const existing = await Promise.all(files.map(async (file) => {
|
|
85
|
+
const stat = await lstat(path.join(cwd, file)).catch((error) => {
|
|
86
|
+
if (error.code === 'ENOENT' || error.code === 'ENOTDIR') return null;
|
|
87
|
+
throw error;
|
|
88
|
+
});
|
|
89
|
+
return stat?.isFile() === true;
|
|
90
|
+
}));
|
|
91
|
+
return files.filter((_, i) => existing[i]);
|
|
92
|
+
}
|
|
93
|
+
|
|
69
94
|
/**
|
|
70
95
|
* An owned entry that names one file: no glob character (`*`, `?`, `{`) and no trailing `/`.
|
|
71
96
|
* @param {string} p @returns {boolean}
|
|
@@ -186,8 +211,6 @@ export function changedScope({ baseline, current, fallback, threshold = DEFAULT_
|
|
|
186
211
|
const changes = [...new Set([...Object.keys(old), ...Object.keys(current.files)])].filter((file) => old[file] !== current.files[file]).sort();
|
|
187
212
|
if (changes.length > threshold) return { scope: 'changed', files: [], changedFiles: [], reason: 'threshold' };
|
|
188
213
|
const changedFiles = changes.filter((file) => candidates.has(file));
|
|
189
|
-
// B97 also pairs Swift Tests names outside lowercase test directories.
|
|
190
|
-
const isChangedTest = (/** @type {string} */ file) => isTestPath(file) || file.endsWith('Tests.swift');
|
|
191
214
|
const tests = new Set(changedFiles.filter(isChangedTest));
|
|
192
215
|
for (const file of changes.filter((f) => !isChangedTest(f))) {
|
|
193
216
|
const pairs = pairsFor(file, current.candidates).filter(isChangedTest);
|
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/install/link.mjs
CHANGED
|
@@ -16,12 +16,14 @@
|
|
|
16
16
|
* harness at the same scope again replaces its record rather than duplicating it.
|
|
17
17
|
*/
|
|
18
18
|
|
|
19
|
-
import { lstatSync, readdirSync, realpathSync } from 'node:fs';
|
|
19
|
+
import { lstatSync, readFileSync, readdirSync, realpathSync } from 'node:fs';
|
|
20
20
|
import { cp, lstat, mkdir, readFile, rename, rm, stat, symlink, unlink, writeFile } from 'node:fs/promises';
|
|
21
21
|
import os from 'node:os';
|
|
22
22
|
import path from 'node:path';
|
|
23
23
|
import { randomUUID } from 'node:crypto';
|
|
24
24
|
|
|
25
|
+
const PACKAGE_VERSION = JSON.parse(readFileSync(new URL('../../package.json', import.meta.url), 'utf8')).version;
|
|
26
|
+
|
|
25
27
|
/** @typedef {"symlink"|"copy"} LinkMethod */
|
|
26
28
|
/** @typedef {"project"|"global"} InstallScope */
|
|
27
29
|
|
|
@@ -33,6 +35,7 @@ import { randomUUID } from 'node:crypto';
|
|
|
33
35
|
* @property {string} source - absolute path to the skill directory that was linked/copied.
|
|
34
36
|
* @property {string} target - absolute path to the installed skill directory.
|
|
35
37
|
* @property {string} linked_at - ISO timestamp of the most recent link/upgrade.
|
|
38
|
+
* @property {string} [version] - package version that last linked this install; absent on legacy records.
|
|
36
39
|
*/
|
|
37
40
|
|
|
38
41
|
/**
|
|
@@ -66,7 +69,8 @@ function isValidRecord(value) {
|
|
|
66
69
|
typeof r.target === 'string' &&
|
|
67
70
|
r.target.length > 0 &&
|
|
68
71
|
path.isAbsolute(r.target) &&
|
|
69
|
-
typeof r.linked_at === 'string'
|
|
72
|
+
typeof r.linked_at === 'string' &&
|
|
73
|
+
(r.version === undefined || (typeof r.version === 'string' && r.version.length > 0))
|
|
70
74
|
);
|
|
71
75
|
}
|
|
72
76
|
|
|
@@ -359,13 +363,14 @@ export async function linkSkill({ source, target, method }) {
|
|
|
359
363
|
* @param {LinkMethod} opts.method
|
|
360
364
|
* @param {string} opts.source
|
|
361
365
|
* @param {string} opts.target
|
|
366
|
+
* @param {string} [opts.version] - defaults to the running package version.
|
|
362
367
|
* @returns {Promise<InstallRecord[]>} the full record list after this install.
|
|
363
368
|
*/
|
|
364
|
-
export async function install({ installsFile, harness, scope, method, source, target }) {
|
|
369
|
+
export async function install({ installsFile, harness, scope, method, source, target, version = PACKAGE_VERSION }) {
|
|
365
370
|
await linkSkill({ source, target, method });
|
|
366
371
|
const existing = await readInstalls(installsFile);
|
|
367
372
|
const kept = existing.filter((r) => !(r.harness === harness && r.scope === scope));
|
|
368
|
-
const records = [...kept, { harness, scope, method, source, target, linked_at: new Date().toISOString() }];
|
|
373
|
+
const records = [...kept, { harness, scope, method, source, target, linked_at: new Date().toISOString(), version }];
|
|
369
374
|
await writeInstallsAtomically(installsFile, records);
|
|
370
375
|
return records;
|
|
371
376
|
}
|
|
@@ -379,15 +384,16 @@ export async function install({ installsFile, harness, scope, method, source, ta
|
|
|
379
384
|
* @param {object} opts
|
|
380
385
|
* @param {string} opts.installsFile
|
|
381
386
|
* @param {string} [opts.source] - overrides every record's source when given.
|
|
387
|
+
* @param {string} [opts.version] - defaults to the running package version.
|
|
382
388
|
* @returns {Promise<InstallRecord[]>} the updated record list (same length as before).
|
|
383
389
|
*/
|
|
384
|
-
export async function upgradeAll({ installsFile, source }) {
|
|
390
|
+
export async function upgradeAll({ installsFile, source, version = PACKAGE_VERSION }) {
|
|
385
391
|
const records = await readInstalls(installsFile);
|
|
386
392
|
const updated = [];
|
|
387
393
|
for (const record of records) {
|
|
388
394
|
const nextSource = source ?? record.source;
|
|
389
395
|
await linkSkill({ source: nextSource, target: record.target, method: record.method });
|
|
390
|
-
updated.push({ ...record, source: nextSource, linked_at: new Date().toISOString() });
|
|
396
|
+
updated.push({ ...record, source: nextSource, linked_at: new Date().toISOString(), version });
|
|
391
397
|
}
|
|
392
398
|
await writeInstallsAtomically(installsFile, updated);
|
|
393
399
|
return updated;
|
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
|
},
|