@codedology/code-forge 0.15.1 → 0.15.3
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 +62 -0
- package/README.md +5 -1
- package/docs/README.md +2 -0
- package/docs/reference/blocks.json +154 -1
- package/docs/reference/config.md +27 -14
- package/package.json +1 -1
- package/schema/code-forge.schema.json +57 -2
- package/schema/outcome.schema.json +45 -0
- package/schema/pipeline.schema.json +30 -0
- package/scripts/first-use.mjs +8 -0
- package/scripts/gen-config-doc.mjs +1 -1
- package/skill/references/code.md +15 -0
- package/skill/references/ledger.md +13 -1
- package/skill/scripts/forge +1 -1
- package/src/cli/block.mjs +146 -30
- package/src/cli/context.mjs +17 -7
- package/src/cli/gates.mjs +76 -10
- package/src/cli/help.mjs +4 -0
- package/src/cli/outcome.mjs +87 -0
- package/src/cli/pipeline.mjs +61 -0
- package/src/cli/proof.mjs +10 -7
- package/src/cli/report.mjs +53 -6
- package/src/cli/review-file.mjs +168 -38
- package/src/cli/run.mjs +3 -2
- package/src/cli/sweep.mjs +28 -0
- package/src/cli/validate.mjs +6 -0
- package/src/config/schema-compile.mjs +6 -4
- package/src/config/validate.mjs +83 -1
- package/src/context/pack.mjs +53 -3
- package/src/doctor/local.mjs +22 -4
- package/src/engines/builders/codex.mjs +27 -7
- package/src/engines/builders/grok.mjs +11 -5
- package/src/engines/codex-home.mjs +18 -15
- package/src/engines/sentinel.mjs +11 -0
- package/src/gates/detect.mjs +6 -3
- package/src/gates/run.mjs +295 -27
- package/src/ledger/export.mjs +1 -1
- package/src/ledger/report.mjs +143 -5
- package/src/outcome/outcome.mjs +108 -0
- package/src/pipeline/pipeline.mjs +112 -0
- package/src/proof/red-green.mjs +41 -13
- package/src/proof/runners.mjs +28 -4
- package/src/proof/worktree.mjs +25 -15
- package/src/review/agent-waiver.mjs +18 -4
- package/src/review/engine.mjs +25 -5
- package/src/review/gate-check.mjs +39 -1
- package/src/review/lenses/full.md +1 -0
- package/src/review/lenses/quick.md +1 -0
- package/src/review/packet.mjs +34 -6
- package/src/review/related.mjs +112 -0
- package/src/review/reopen.mjs +4 -3
- package/src/review/session-retry.mjs +59 -0
- package/src/session/facts.mjs +18 -4
- package/src/session/spawn.mjs +54 -3
- package/src/state/block.mjs +101 -34
- package/src/state/config-snapshot.mjs +13 -3
- package/src/state/run.mjs +3 -2
- package/src/util/exec.mjs +48 -31
- package/src/util/reaper.mjs +238 -8
- package/src/worker/engine.mjs +7 -3
- package/src/worker/loop.mjs +67 -14
- package/src/worker/queue.mjs +106 -23
- package/src/worker/watch.mjs +2 -2
package/src/cli/gates.mjs
CHANGED
|
@@ -33,11 +33,14 @@
|
|
|
33
33
|
*/
|
|
34
34
|
|
|
35
35
|
import { realpathSync } from 'node:fs';
|
|
36
|
-
import { readFile } from 'node:fs/promises';
|
|
36
|
+
import { readFile, stat } from 'node:fs/promises';
|
|
37
37
|
import path from 'node:path';
|
|
38
38
|
import { writeSafe, redactJSON } from '../util/redact.mjs';
|
|
39
|
+
import { exec } from '../util/exec.mjs';
|
|
39
40
|
import { appendRow } from '../ledger/write.mjs';
|
|
40
41
|
import { loadProjectConfig } from '../config/load.mjs';
|
|
42
|
+
import { customGateMessages, kindGateMessages } from '../config/validate.mjs';
|
|
43
|
+
import { blockKind } from '../decide/escalation.mjs';
|
|
41
44
|
import { detectGates } from '../gates/detect.mjs';
|
|
42
45
|
import { printableGates, scopeFields, testScope, thresholdOf } from '../gates/expand.mjs';
|
|
43
46
|
import { runGates } from '../gates/run.mjs';
|
|
@@ -55,6 +58,18 @@ import { checkTranscript } from '../gates/transcript-grep.mjs';
|
|
|
55
58
|
/** B60: the gate env rule, in the same words as the schema and proof.md. */
|
|
56
59
|
const GATE_ENV_RULE = 'code-forge never prints or records an env value. Gate env is not a secret store. Child output is masked only for values of 8 or more characters.';
|
|
57
60
|
|
|
61
|
+
/** Per-gate timeout: explicit milliseconds, configured whole seconds, then five minutes.
|
|
62
|
+
* @param {{flag?: number, config?: Record<string, any> | null}} opts
|
|
63
|
+
* @returns {number}
|
|
64
|
+
*/
|
|
65
|
+
export function gateTimeoutMs({ flag, config }) {
|
|
66
|
+
const seconds = config?.gates?.timeout_s;
|
|
67
|
+
if (config?.gates && Object.hasOwn(config.gates, 'timeout_s') && (!Number.isInteger(seconds) || seconds < 30 || seconds > 7200)) {
|
|
68
|
+
throw new TypeError('gates.timeout_s must be an integer 30..7200');
|
|
69
|
+
}
|
|
70
|
+
return flag ?? (seconds === undefined ? 300000 : seconds * 1000);
|
|
71
|
+
}
|
|
72
|
+
|
|
58
73
|
/**
|
|
59
74
|
* Auto-detected gates (`detectGates`), overridden per-key by an on-disk `.code-forge.yml`'s
|
|
60
75
|
* `gates.{test,lint,types,format}` when that key is explicitly present — a project that already
|
|
@@ -62,17 +77,20 @@ const GATE_ENV_RULE = 'code-forge never prints or records an env value. Gate env
|
|
|
62
77
|
* A missing/unparseable config file is not an error here: detection alone is a valid answer.
|
|
63
78
|
* @param {string} cwd
|
|
64
79
|
* @param {Record<string, any> | null} configured - the config's `gates` section ({@link configuredGates}).
|
|
65
|
-
* @returns {import('../gates/detect.mjs').DetectedGates}
|
|
80
|
+
* @returns {import('../gates/detect.mjs').DetectedGates & {custom?: import('../gates/run.mjs').CustomGate[]}}
|
|
66
81
|
*/
|
|
67
82
|
function resolveGates(cwd, configured) {
|
|
68
83
|
const detected = detectGates(cwd);
|
|
69
84
|
if (!configured) return detected;
|
|
85
|
+
const errors = [...customGateMessages(configured.custom), ...kindGateMessages(configured)];
|
|
86
|
+
if (errors.length > 0) throw new TypeError(errors.join('; '));
|
|
70
87
|
return {
|
|
71
88
|
stack: detected.stack,
|
|
72
89
|
test: 'test' in configured ? configured.test : detected.test,
|
|
73
90
|
lint: 'lint' in configured ? configured.lint : detected.lint,
|
|
74
91
|
types: 'types' in configured ? configured.types : detected.types,
|
|
75
92
|
format: 'format' in configured ? configured.format : detected.format,
|
|
93
|
+
...(configured.custom !== undefined ? { custom: configured.custom } : {}),
|
|
76
94
|
};
|
|
77
95
|
}
|
|
78
96
|
|
|
@@ -183,6 +201,30 @@ function realOrResolved(p) {
|
|
|
183
201
|
}
|
|
184
202
|
}
|
|
185
203
|
|
|
204
|
+
/** Warn once per dependency-bearing directory, including a linked worktree's main checkout.
|
|
205
|
+
* @param {string} cwd @param {(line: string) => void} err
|
|
206
|
+
* @returns {Promise<void>}
|
|
207
|
+
*/
|
|
208
|
+
async function warnMissingDependencies(cwd, err) {
|
|
209
|
+
const dirs = new Set([realOrResolved(cwd)]);
|
|
210
|
+
const git = await exec(['git', 'rev-parse', '--show-toplevel', '--git-common-dir'], { cwd, timeoutMs: 5000 });
|
|
211
|
+
if (git.result === 'ok') {
|
|
212
|
+
const [top, common] = git.stdout.trimEnd().split('\n');
|
|
213
|
+
if (top) dirs.add(realOrResolved(top));
|
|
214
|
+
if (common) dirs.add(realOrResolved(path.dirname(path.resolve(cwd, common))));
|
|
215
|
+
}
|
|
216
|
+
for (const dir of dirs) {
|
|
217
|
+
let manifest;
|
|
218
|
+
try { manifest = JSON.parse(await readFile(path.join(dir, 'package.json'), 'utf8')); }
|
|
219
|
+
catch { continue; }
|
|
220
|
+
const hasDeps = [manifest?.dependencies, manifest?.devDependencies].some((deps) =>
|
|
221
|
+
deps && typeof deps === 'object' && !Array.isArray(deps) && Object.keys(deps).length > 0);
|
|
222
|
+
if (!hasDeps) continue;
|
|
223
|
+
const installed = await stat(path.join(dir, 'node_modules')).then((entry) => entry.isDirectory(), () => false);
|
|
224
|
+
if (!installed) err(`warn: ${dir} has package.json but no node_modules; run npm ci there first\n`);
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
186
228
|
/**
|
|
187
229
|
* B60: the scope fields of a `{test_files}` gate's result (`scope` with `test_count` or `reason`),
|
|
188
230
|
* or none for any other gate.
|
|
@@ -218,7 +260,7 @@ export async function runGatesVerb(args, deps = {}) {
|
|
|
218
260
|
if (!cwdArg.ok) return 2;
|
|
219
261
|
const gates = resolveGates(cwdArg.value, await configuredGates(cwdArg.value));
|
|
220
262
|
// B60: a gate given with env is printed with its variable NAMES (`env_keys`), never the values
|
|
221
|
-
out(printableGates(gates));
|
|
263
|
+
out(printableGates({ ...gates, ...(gates.custom ? { custom: gates.custom.map((gate) => printableGates({ gate }).gate) } : {}) }));
|
|
222
264
|
return 0;
|
|
223
265
|
}
|
|
224
266
|
|
|
@@ -232,8 +274,8 @@ export async function runGatesVerb(args, deps = {}) {
|
|
|
232
274
|
err('gates run: --timeout-ms requires a value\n');
|
|
233
275
|
return 2;
|
|
234
276
|
}
|
|
235
|
-
const
|
|
236
|
-
if (
|
|
277
|
+
const flagTimeoutMs = timeoutFlag.value !== undefined ? Number(timeoutFlag.value) : undefined;
|
|
278
|
+
if (flagTimeoutMs !== undefined && !(Number.isFinite(flagTimeoutMs) && flagTimeoutMs > 0)) {
|
|
237
279
|
err(`gates run: --timeout-ms must be a finite number > 0, got "${timeoutFlag.value}"\n`);
|
|
238
280
|
return 2;
|
|
239
281
|
}
|
|
@@ -275,12 +317,15 @@ export async function runGatesVerb(args, deps = {}) {
|
|
|
275
317
|
cwd = placed.tree.workspace;
|
|
276
318
|
}
|
|
277
319
|
const configured = await configuredGates(placed ? placed.workspace : cwd); // loaded once: the gates and the threshold
|
|
320
|
+
const timeoutMs = gateTimeoutMs({ flag: flagTimeoutMs, config: { gates: configured } });
|
|
278
321
|
const gates = resolveGates(cwd, configured);
|
|
322
|
+
await warnMissingDependencies(cwd, err);
|
|
279
323
|
const inTree = placed ? { worktree: placed.tree.path } : {};
|
|
280
324
|
let ran;
|
|
325
|
+
let message;
|
|
281
326
|
if (runFlag.value !== undefined && blockFlag.value !== undefined) {
|
|
282
327
|
const threshold = thresholdOf(configured?.full_suite_threshold_files);
|
|
283
|
-
/** @type {typeof gates & {repeatNewTests?: import('../gates/run.mjs').RepeatNewTests, lightTestFiles?: string[]}} */
|
|
328
|
+
/** @type {typeof gates & {repeatNewTests?: import('../gates/run.mjs').RepeatNewTests, lightTestFiles?: string[], kindGates?: import('../gates/run.mjs').KindGates}} */
|
|
284
329
|
let planned = gates;
|
|
285
330
|
if (configured?.repeat_new_tests !== undefined) {
|
|
286
331
|
const record = await readRun(runFlag.value);
|
|
@@ -295,6 +340,14 @@ export async function runGatesVerb(args, deps = {}) {
|
|
|
295
340
|
throw error;
|
|
296
341
|
});
|
|
297
342
|
const light = block?.kind === 'light';
|
|
343
|
+
if (block && !light) {
|
|
344
|
+
const kind = blockKind({ declared: block.kind, owned: block.owned_files });
|
|
345
|
+
const names = configured?.kinds?.[kind];
|
|
346
|
+
if (Array.isArray(names)) {
|
|
347
|
+
planned = { ...planned, kindGates: { kind, names } };
|
|
348
|
+
if (names.length === 0) message = `0 gates for kind ${kind}`;
|
|
349
|
+
}
|
|
350
|
+
}
|
|
298
351
|
if (light) {
|
|
299
352
|
const scope = testScope({ testFiles: block.test_files, owned: block.owned_files, threshold: Infinity });
|
|
300
353
|
const command = planned.test;
|
|
@@ -302,16 +355,20 @@ export async function runGatesVerb(args, deps = {}) {
|
|
|
302
355
|
if (scope.scope !== 'related' || !argv?.includes('{test_files}')) throw new StateError('light-tests', 'light-tests: block tests and a gates.test {test_files} item are required');
|
|
303
356
|
planned = { ...planned, lightTestFiles: scope.files };
|
|
304
357
|
}
|
|
305
|
-
const guarded = await runGatesGuarded({ runId: runFlag.value, blockId: blockFlag.value, gates: planned, cwd, threshold, changed: light ? false : changed, slug,
|
|
358
|
+
const guarded = await runGatesGuarded({ runId: runFlag.value, blockId: blockFlag.value, gates: planned, cwd, threshold, changed: light ? false : changed, slug, timeoutMs });
|
|
306
359
|
if (guarded.result === 'proof.busy') {
|
|
307
360
|
out({ result: 'proof.busy', holder: /** @type {{holder: string}} */ (guarded).holder });
|
|
308
361
|
return 1;
|
|
309
362
|
}
|
|
310
363
|
ran = /** @type {{value: {results: any[], allOk: boolean, ledger_slug?: string, files?: Record<string, string>, changed_scope?: import('../gates/expand.mjs').ChangedScope}}} */ (guarded).value;
|
|
311
364
|
} else {
|
|
312
|
-
ran = await runGates(gates, { cwd,
|
|
365
|
+
ran = await runGates(gates, { cwd, timeoutMs });
|
|
313
366
|
}
|
|
314
367
|
const { results, allOk } = ran;
|
|
368
|
+
const durationMs = results.reduce((sum, r) => sum + r.duration_ms, 0);
|
|
369
|
+
for (const result of results) {
|
|
370
|
+
if (!result.skipped && result.code === null) err(`${result.gate}: ${result.message}\n`);
|
|
371
|
+
}
|
|
315
372
|
const repeats = results.find((r) => r.repeats)?.repeats;
|
|
316
373
|
|
|
317
374
|
const ledgerSlug = slug ?? ran.ledger_slug;
|
|
@@ -325,18 +382,27 @@ export async function runGatesVerb(args, deps = {}) {
|
|
|
325
382
|
for (const result of results) {
|
|
326
383
|
if (!result.skipped && !result.ok) {
|
|
327
384
|
await writeGateRow({ ...owner, event: 'gate.red', gate: result.gate, code: result.code, ...scopeOf(result),
|
|
328
|
-
|
|
385
|
+
duration_ms: result.duration_ms, total_duration_ms: durationMs,
|
|
386
|
+
...(result.custom ? { custom: true } : {}),
|
|
387
|
+
...(result.reason ? { reason: result.reason } : {}),
|
|
329
388
|
...(result.repeats ? { repeats: result.repeats } : {}) });
|
|
330
389
|
}
|
|
331
390
|
}
|
|
332
391
|
// B60: one scope per run (every `{test_files}` gate shares it), named on gate.done too;
|
|
333
392
|
// without --run/--block that is `scope: full, reason: no_tests`
|
|
334
393
|
const scoped = results.find((r) => r.scope !== undefined);
|
|
394
|
+
const custom = results.filter((r) => r.custom);
|
|
335
395
|
await writeGateRow({ ...owner, event: 'gate.done', stack: gates.stack, all_ok: allOk,
|
|
396
|
+
duration_ms: durationMs,
|
|
397
|
+
results: results.map(({ gate, ok, skipped, duration_ms }) => ({ gate, ok, skipped, duration_ms })),
|
|
336
398
|
...(ran.changed_scope ? scopeFields(ran.changed_scope) : scoped ? scopeOf(scoped) : {}),
|
|
399
|
+
...(custom.length ? {
|
|
400
|
+
custom: custom.map((r) => ({ name: r.gate, ok: r.ok, evidence_count: r.evidence.length, ...(r.missing ? { missing: r.missing } : {}) })),
|
|
401
|
+
...(runFlag.value !== undefined ? { evidence: Object.fromEntries(custom.map((r) => [r.gate, r.evidence])) } : {}),
|
|
402
|
+
} : {}),
|
|
337
403
|
...(ran.files ? { files: ran.files } : {}), ...(repeats ? { repeats } : {}) });
|
|
338
404
|
}
|
|
339
|
-
out({ stack: gates.stack, ...inTree, results, allOk, ...(repeats ? { repeats } : {}) });
|
|
405
|
+
out({ stack: gates.stack, ...inTree, results, allOk, duration_ms: durationMs, ...(message ? { message } : {}), ...(repeats ? { repeats } : {}) });
|
|
340
406
|
return allOk ? 0 : 1;
|
|
341
407
|
}
|
|
342
408
|
|
package/src/cli/help.mjs
CHANGED
|
@@ -44,5 +44,9 @@ export default async function help(_args, context = {}) {
|
|
|
44
44
|
for (const verb of verbs) {
|
|
45
45
|
process.stdout.write(` ${verb}\n`);
|
|
46
46
|
}
|
|
47
|
+
process.stdout.write('\nBlock usage: code-forge block <verb> --help (or -h).\n');
|
|
48
|
+
process.stdout.write(' block open --acceptance <file>: JSON/YAML list [{clause, tests}]; tests is a nonempty list of test ids.\n');
|
|
49
|
+
process.stdout.write(' block open --brief <file>: a file inside the repository, not inline text.\n');
|
|
50
|
+
process.stdout.write(' Configuration fallback entries must be {provider, model} objects, not model-name strings (optional effort).\n');
|
|
47
51
|
return 0;
|
|
48
52
|
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { readFileSync } from 'node:fs';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { loadProjectConfig, slugFor } from '../config/load.mjs';
|
|
5
|
+
import { appendRow, readAllRows } from '../ledger/write.mjs';
|
|
6
|
+
import { checkOutcome, sealOutcome } from '../outcome/outcome.mjs';
|
|
7
|
+
import { loadPipeline } from '../pipeline/pipeline.mjs';
|
|
8
|
+
import { withRunLock, writeSigned } from '../state/run.mjs';
|
|
9
|
+
|
|
10
|
+
const USAGE = 'usage: code-forge outcome seal|check|record <outcome.json> [--root <dir>] [--step <id> (record only)] [--json (check or record)]\n';
|
|
11
|
+
|
|
12
|
+
/** @param {string} file @param {string} root @param {string | undefined} explicitStep */
|
|
13
|
+
async function recordOutcome(file, root, explicitStep) {
|
|
14
|
+
const bytes = readFileSync(file);
|
|
15
|
+
const obj = JSON.parse(bytes.toString('utf8'));
|
|
16
|
+
const sha256 = createHash('sha256').update(bytes).digest('hex');
|
|
17
|
+
const pipeline = loadPipeline(root);
|
|
18
|
+
const step = explicitStep ?? (pipeline.exists && pipeline.ok
|
|
19
|
+
? pipeline.pipeline.steps.find((s) => path.resolve(root, s.outcome) === path.resolve(file))?.id
|
|
20
|
+
: undefined) ?? null;
|
|
21
|
+
const loaded = await loadProjectConfig(root);
|
|
22
|
+
if (!loaded.ok && loaded.error !== 'not-found') throw new Error(`project config: ${loaded.message}`);
|
|
23
|
+
const slug = slugFor(loaded.ok ? loaded.config : {}, root);
|
|
24
|
+
const rows = await readAllRows(slug);
|
|
25
|
+
const run = rows.filter((r) => r.event === 'run.start' && typeof r.run === 'string').at(-1)?.run;
|
|
26
|
+
if (!run) throw new Error('no project run to sign the outcome; start a run first');
|
|
27
|
+
return withRunLock(run, async () => {
|
|
28
|
+
const current = await readAllRows(slug);
|
|
29
|
+
if (current.some((r) => r.event === 'outcome.recorded' && r.sha256 === sha256 && r.step === step)) {
|
|
30
|
+
return { ok: true, problems: [], already_recorded: true };
|
|
31
|
+
}
|
|
32
|
+
const row = await writeSigned(run, (signed) => appendRow(signed, { slug }), {
|
|
33
|
+
event: 'outcome.recorded', skill: obj.skill, step, status: obj.status, sha256,
|
|
34
|
+
cost_usd: obj.cost_usd ?? null, minutes: obj.minutes ?? null,
|
|
35
|
+
});
|
|
36
|
+
return { ok: true, problems: [], already_recorded: false, row };
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** @param {string[]} args @returns {Promise<number>} */
|
|
41
|
+
export default async function outcome(args) {
|
|
42
|
+
const [command, ...rest] = args;
|
|
43
|
+
let file;
|
|
44
|
+
let root;
|
|
45
|
+
let step;
|
|
46
|
+
let json = false;
|
|
47
|
+
const usage = () => { process.stderr.write(USAGE); return 2; };
|
|
48
|
+
if (!['seal', 'check', 'record'].includes(command)) return usage();
|
|
49
|
+
for (let i = 0; i < rest.length; i += 1) {
|
|
50
|
+
const arg = rest[i];
|
|
51
|
+
if (arg === '--root') {
|
|
52
|
+
if (root !== undefined || !rest[i + 1] || rest[i + 1].startsWith('-')) return usage();
|
|
53
|
+
root = rest[++i];
|
|
54
|
+
} else if (arg === '--step' && command === 'record') {
|
|
55
|
+
if (step !== undefined || !rest[i + 1] || rest[i + 1].startsWith('-')) return usage();
|
|
56
|
+
step = rest[++i];
|
|
57
|
+
} else if (arg === '--json' && command !== 'seal' && !json) {
|
|
58
|
+
json = true;
|
|
59
|
+
} else if (arg.startsWith('-') || file !== undefined) {
|
|
60
|
+
return usage();
|
|
61
|
+
} else {
|
|
62
|
+
file = arg;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
if (!file) return usage();
|
|
66
|
+
let result = command === 'seal' ? sealOutcome(file, { root }) : checkOutcome(file, { root });
|
|
67
|
+
let alreadyRecorded = false;
|
|
68
|
+
if (command === 'record' && result.ok) {
|
|
69
|
+
try {
|
|
70
|
+
const recorded = await recordOutcome(file, path.resolve(root ?? process.cwd()), step);
|
|
71
|
+
alreadyRecorded = recorded.already_recorded;
|
|
72
|
+
result = recorded;
|
|
73
|
+
} catch (err) {
|
|
74
|
+
result = { ok: false, problems: [err.message] };
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
if (json) {
|
|
78
|
+
process.stdout.write(`${JSON.stringify(result)}\n`);
|
|
79
|
+
} else if (!result.ok) {
|
|
80
|
+
for (const problem of result.problems) process.stderr.write(`${problem}\n`);
|
|
81
|
+
} else if (command === 'check') {
|
|
82
|
+
process.stdout.write('ok\n');
|
|
83
|
+
} else if (command === 'record') {
|
|
84
|
+
process.stdout.write(alreadyRecorded ? 'already recorded\n' : 'recorded\n');
|
|
85
|
+
}
|
|
86
|
+
return result.ok ? 0 : 1;
|
|
87
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import { loadPipeline, pipelineStatus } from '../pipeline/pipeline.mjs';
|
|
3
|
+
|
|
4
|
+
const USAGE = 'usage: code-forge pipeline status|next [--json] [--root <dir>]\n';
|
|
5
|
+
|
|
6
|
+
/** @param {string[]} args @returns {Promise<number>} */
|
|
7
|
+
export default async function pipeline(args) {
|
|
8
|
+
const [command, ...rest] = args;
|
|
9
|
+
let root;
|
|
10
|
+
let json = false;
|
|
11
|
+
const usage = () => { process.stderr.write(USAGE); return 2; };
|
|
12
|
+
if (command !== 'status' && command !== 'next') return usage();
|
|
13
|
+
for (let i = 0; i < rest.length; i += 1) {
|
|
14
|
+
if (rest[i] === '--root') {
|
|
15
|
+
if (root !== undefined || !rest[i + 1] || rest[i + 1].startsWith('-')) return usage();
|
|
16
|
+
root = rest[++i];
|
|
17
|
+
} else if (rest[i] === '--json' && !json) {
|
|
18
|
+
json = true;
|
|
19
|
+
} else {
|
|
20
|
+
return usage();
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
root = path.resolve(root ?? process.cwd());
|
|
24
|
+
const loaded = loadPipeline(root);
|
|
25
|
+
if (!loaded.exists) {
|
|
26
|
+
process.stdout.write(json ? '{"exists":false}\n' : `no code-forge.pipeline.yml in ${root}\n`);
|
|
27
|
+
return 0;
|
|
28
|
+
}
|
|
29
|
+
if (!loaded.ok) {
|
|
30
|
+
for (const problem of loaded.problems) process.stderr.write(`${problem}\n`);
|
|
31
|
+
return 1;
|
|
32
|
+
}
|
|
33
|
+
const steps = pipelineStatus(root);
|
|
34
|
+
if (command === 'status') {
|
|
35
|
+
if (json) {
|
|
36
|
+
process.stdout.write(`${JSON.stringify({ exists: true, steps })}\n`);
|
|
37
|
+
} else {
|
|
38
|
+
for (const step of steps) {
|
|
39
|
+
process.stdout.write(`${step.id} ${step.state} ${step.skill}${step.problem ? ` — ${step.problem}` : ''}\n`);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
return 0;
|
|
43
|
+
}
|
|
44
|
+
const states = new Map(steps.map((step) => [step.id, step.state]));
|
|
45
|
+
const ready = steps.filter((step) => step.state === 'ready').map((step) => step.id);
|
|
46
|
+
const waiting = loaded.pipeline.steps.filter((step) => states.get(step.id) === 'waiting').map((step) => ({
|
|
47
|
+
id: step.id, needs: step.needs.filter((id) => states.get(id) !== 'approved'),
|
|
48
|
+
}));
|
|
49
|
+
const done = steps.every((step) => step.state === 'approved');
|
|
50
|
+
if (json) {
|
|
51
|
+
process.stdout.write(`${JSON.stringify({ exists: true, ready, waiting, done })}\n`);
|
|
52
|
+
} else if (ready.length > 0) {
|
|
53
|
+
process.stdout.write(`${ready.join('\n')}\n`);
|
|
54
|
+
} else if (done) {
|
|
55
|
+
process.stdout.write('all steps approved\n');
|
|
56
|
+
} else {
|
|
57
|
+
process.stdout.write('nothing ready\n');
|
|
58
|
+
for (const step of waiting) process.stdout.write(`${step.id} waiting for ${step.needs.join(', ')}\n`);
|
|
59
|
+
}
|
|
60
|
+
return 0;
|
|
61
|
+
}
|
package/src/cli/proof.mjs
CHANGED
|
@@ -45,7 +45,7 @@ import { redactJSON, writeSafe } from '../util/redact.mjs';
|
|
|
45
45
|
import { DEFAULT_COPY_UNTRACKED, DEFAULT_LINK_DIRS, buildExport, exportDirFor, recordProof, removeExport } from '../proof/export.mjs';
|
|
46
46
|
import { acquireProofLock, lockHolder, releaseProofLock } from '../proof/lock.mjs';
|
|
47
47
|
import { MECHANISMS, readJournal, restoreJournal, runRedGreen } from '../proof/red-green.mjs';
|
|
48
|
-
import { adapterPrecheck, runnerCovers, runnerHook, runnerTimeoutMs, selectRunner } from '../proof/runners.mjs';
|
|
48
|
+
import { adapterPrecheck, defaultPestRunner, runnerCovers, runnerHook, runnerTimeoutMs, selectRunner } from '../proof/runners.mjs';
|
|
49
49
|
import { tierFor } from '../proof/tiers.mjs';
|
|
50
50
|
import { blockTree } from '../proof/worktree.mjs';
|
|
51
51
|
import { blockFileSet, repoRelativePath } from '../review/gate-check.mjs';
|
|
@@ -282,18 +282,17 @@ export async function runProof(args, deps = {}) {
|
|
|
282
282
|
const file = normalizeTestPath(sep < 0 ? flags.test : flags.test.slice(0, sep));
|
|
283
283
|
const runId = flags.run;
|
|
284
284
|
const record = await readRun(runId);
|
|
285
|
-
|
|
286
|
-
|
|
285
|
+
const block = record.blocks?.[blockId];
|
|
286
|
+
if (block?.status !== 'open') throw new StateError('no-block', `block ${blockId} is not open in run ${runId}`);
|
|
287
|
+
const place = await proofPlace(runId, record, blockId, block);
|
|
288
|
+
// Explicit commands come from the snapshot; unmatched PHP tests get the Pest default.
|
|
287
289
|
const runners = proofRunners(record);
|
|
288
|
-
const entry = selectRunner(runners, file);
|
|
290
|
+
const entry = selectRunner(runners, file) ?? await defaultPestRunner(place.dir, file, testCase);
|
|
289
291
|
const runner = entry === null ? null : (hook(entry, { file, testCase }) ?? null);
|
|
290
292
|
// a matched entry is run by its hook or not at all: never a node:test fallback (no row, no precheck)
|
|
291
293
|
if (entry !== null && runner === null) throw new StateError('usage', `the ${entry.adapter} runner for ${file} has no hook; no proof was run`);
|
|
292
294
|
// exactly one of the two: the built-in argv without a runner, the runner (its own argv) with one
|
|
293
295
|
const how = runner === null || entry === null ? { argv: filteredTestArgv(file, testCase) } : { runner, timeoutMs: runnerTimeoutMs(entry) };
|
|
294
|
-
const block = record.blocks?.[blockId];
|
|
295
|
-
if (block?.status !== 'open') throw new StateError('no-block', `block ${blockId} is not open in run ${runId}`);
|
|
296
|
-
const place = await proofPlace(runId, record, blockId, block);
|
|
297
296
|
await assertTestFile(place.dir, file);
|
|
298
297
|
const writeRow = (/** @type {Record<string, any>} */ row) => appendRow(row, { slug: record.project });
|
|
299
298
|
const config = await configAt(record.workspace);
|
|
@@ -327,6 +326,7 @@ export async function runProof(args, deps = {}) {
|
|
|
327
326
|
test: { file, ...(testCase === undefined ? {} : { case: testCase }), characterization: mechanism === 'assertion-deletion' },
|
|
328
327
|
sources,
|
|
329
328
|
isolation,
|
|
329
|
+
setup: exp ? config?.proof?.worktree?.setup ?? [] : [],
|
|
330
330
|
print: (line) => err(`${line}\n`),
|
|
331
331
|
});
|
|
332
332
|
// B20: the signed row names what the proof covers — the block gate reads `covers` per
|
|
@@ -343,6 +343,7 @@ export async function runProof(args, deps = {}) {
|
|
|
343
343
|
// values are never shown
|
|
344
344
|
const runnerFields =
|
|
345
345
|
runner === null ? {} : { runner: runner.adapter, failed_tests: red !== null && 'failed_tests' in red ? red.failed_tests : [], env_keys: [...runner.env_keys] };
|
|
346
|
+
const setupFields = result.reason === 'setup' ? { reason: result.reason, output_tail: result.output_tail } : {};
|
|
346
347
|
await recordProof({
|
|
347
348
|
runId,
|
|
348
349
|
writeRow,
|
|
@@ -359,6 +360,7 @@ export async function runProof(args, deps = {}) {
|
|
|
359
360
|
failed: red?.failed ?? null,
|
|
360
361
|
covers,
|
|
361
362
|
...runnerFields,
|
|
363
|
+
...setupFields,
|
|
362
364
|
},
|
|
363
365
|
});
|
|
364
366
|
out({
|
|
@@ -371,6 +373,7 @@ export async function runProof(args, deps = {}) {
|
|
|
371
373
|
green: result.green ?? null,
|
|
372
374
|
proven,
|
|
373
375
|
...runnerFields,
|
|
376
|
+
...setupFields,
|
|
374
377
|
...(exp ? { export: { dir: exp.dir, restored: exp.restored, untracked: exp.untracked } } : {}),
|
|
375
378
|
});
|
|
376
379
|
return proven ? 0 : 1;
|
package/src/cli/report.mjs
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* `code-forge report [--slug <slug>] [--json] [--export <dir>]` (plan §6.2, R4). Terminal output
|
|
3
|
-
* only, plus an optional `--export <dir>` that writes the same
|
|
4
|
-
* terminal output
|
|
5
|
-
*
|
|
3
|
+
* only, plus an optional `--export <dir>` that writes the same sections as JSON files. The
|
|
4
|
+
* terminal output includes spend per run (B33) and the mode of each run (B59: brief or plan)
|
|
5
|
+
* before the final specialist_outcomes section. Spend and mode are text only, never in
|
|
6
|
+
* `--json` or `--export`. The 14th section, `outside_review` (B66), is
|
|
6
7
|
* printed as text lines under its header (`outside review: none`, or one line per PR).
|
|
7
8
|
*/
|
|
8
9
|
|
|
@@ -76,11 +77,17 @@ export async function runReport(args, deps = {}) {
|
|
|
76
77
|
}
|
|
77
78
|
|
|
78
79
|
for (const [name, data] of Object.entries(sections)) {
|
|
79
|
-
|
|
80
|
+
if (name === 'specialist_outcomes') continue;
|
|
81
|
+
let body;
|
|
82
|
+
if (name === 'cost_per_block') body = blockTimeLines(data);
|
|
83
|
+
else if (name === 'run_time') body = runTimeLines(data);
|
|
84
|
+
else if (name === 'outside_review') body = outsideReviewLines(data);
|
|
85
|
+
else body = `${JSON.stringify(data, null, 2)}\n`;
|
|
80
86
|
out(`== ${name} ==\n${body}`);
|
|
81
87
|
}
|
|
82
88
|
out(spendLines(buildSpend(rows)));
|
|
83
89
|
out(modeLines(buildRunModes(rows)));
|
|
90
|
+
out(`== specialist_outcomes ==\n${specialistOutcomeLines(sections.specialist_outcomes)}`);
|
|
84
91
|
return 0;
|
|
85
92
|
} catch (err_) {
|
|
86
93
|
err(`report: ${err_?.message ?? String(err_)}\n`);
|
|
@@ -88,15 +95,55 @@ export async function runReport(args, deps = {}) {
|
|
|
88
95
|
}
|
|
89
96
|
}
|
|
90
97
|
|
|
98
|
+
/** @param {{outcomes: any[], total: {count: number, cost_usd: number, minutes: number}}} data */
|
|
99
|
+
function specialistOutcomeLines({ outcomes, total }) {
|
|
100
|
+
const cost = (/** @type {number | null} */ usd) => usd === null ? '-' : `$${usd.toFixed(4)}`;
|
|
101
|
+
const lines = ['skill | step | status | cost_usd | minutes'];
|
|
102
|
+
for (const r of outcomes) lines.push(`${r.skill} | ${r.step ?? '-'} | ${r.status} | ${cost(r.cost_usd)} | ${r.minutes ?? '-'}`);
|
|
103
|
+
lines.push(`total (${total.count} outcomes): ${cost(total.cost_usd)} | ${total.minutes} minutes`);
|
|
104
|
+
return `${lines.join('\n')}\n`;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** @param {number | null} ms @returns {string} */
|
|
108
|
+
function duration(ms) {
|
|
109
|
+
if (ms === null) return '-';
|
|
110
|
+
const seconds = Math.floor(ms / 1000);
|
|
111
|
+
if (seconds < 60) return `${seconds}s`;
|
|
112
|
+
const minutes = Math.floor(seconds / 60);
|
|
113
|
+
if (minutes < 60) return `${minutes}m ${seconds % 60}s`;
|
|
114
|
+
return `${Math.floor(minutes / 60)}h ${minutes % 60}m ${seconds % 60}s`;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** @param {any[]} blocks @returns {string} */
|
|
118
|
+
function blockTimeLines(blocks) {
|
|
119
|
+
const lines = ['run | block | USD | wall | human wait | status'];
|
|
120
|
+
for (const b of blocks) {
|
|
121
|
+
lines.push(`${b.run ?? 'outside runs'} | ${b.block} | $${b.totalUsd.toFixed(4)} | ${duration(b.wallMs)} | ${duration(b.humanWaitMs)} | ${b.completed ? 'completed' : b.stopped ? 'stopped' : 'open'}`);
|
|
122
|
+
lines.push(` level ${b.level ?? '-'} · lane ${b.lane ?? '-'} · coder $${b.coderUsd.toFixed(4)} · review $${b.reviewUsd.toFixed(4)} · S1+S2 $${b.s1s2Usd.toFixed(4)} · facts $${b.factsUsd.toFixed(4)} · other $${b.otherUsd.toFixed(4)} · gates ${duration(b.gateTimeMs ?? null)} · review time ${duration(b.reviewTimeMs ?? null)} · proof ${duration(b.proofTimeMs)} · ${b.unknownUsdSessions} unknown`);
|
|
123
|
+
}
|
|
124
|
+
return `${lines.join('\n')}\n`;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** @param {{runs: any[], outside_runs?: {usd: number, rows: number}, total: {runs: number, wallMs: number, usd: number, blocks_closed: number}}} data @returns {string} */
|
|
128
|
+
function runTimeLines({ runs, outside_runs, total }) {
|
|
129
|
+
const lines = ['run | started | ended | wall | USD | closed | stopped'];
|
|
130
|
+
for (const r of runs) lines.push(`${r.run} | ${r.started ?? '-'} | ${r.open ? 'open' : r.ended ?? '-'} | ${duration(r.wallMs)} | $${r.usd.toFixed(4)} | ${r.blocks_closed} | ${r.blocks_stopped}`);
|
|
131
|
+
if (outside_runs) lines.push(`outside runs: $${outside_runs.usd.toFixed(4)} (${outside_runs.rows} rows, no wall time)`);
|
|
132
|
+
lines.push(`total (${total.runs} run${total.runs === 1 ? '' : 's'}): ${duration(total.wallMs)} | $${total.usd.toFixed(4)} | ${total.blocks_closed} closed`);
|
|
133
|
+
return `${lines.join('\n')}\n`;
|
|
134
|
+
}
|
|
135
|
+
|
|
91
136
|
/**
|
|
92
|
-
* B33: the running spend per run (open blocks included), after the
|
|
137
|
+
* B33: the running spend per run (open blocks included), after the sections. Unknown-price
|
|
93
138
|
* sessions are named, never priced.
|
|
94
139
|
* @param {ReturnType<typeof buildSpend>} spend @returns {string}
|
|
95
140
|
*/
|
|
96
141
|
export function spendLines({ runs, total }) {
|
|
97
142
|
const unknown = (/** @type {number} */ n) => (n > 0 ? ` · ${n} with unknown price (not counted)` : '');
|
|
98
143
|
const lines = ['spend per run (estimated USD, open blocks included):'];
|
|
99
|
-
for (const r of runs) lines.push(` run ${r.run
|
|
144
|
+
for (const r of runs.filter((r) => r.run !== null)) lines.push(` run ${r.run}: $${r.usd.toFixed(4)} · ${r.sessions} session(s)${unknown(r.unknown)}`);
|
|
145
|
+
const outside = runs.find((r) => r.run === null);
|
|
146
|
+
if (outside) lines.push(` outside runs: $${outside.usd.toFixed(4)} · ${outside.sessions} session(s)${unknown(outside.unknown)}`);
|
|
100
147
|
lines.push(` total: $${total.usd.toFixed(4)} · ${total.sessions} session(s)${unknown(total.unknown)}`);
|
|
101
148
|
return `${lines.join('\n')}\n`;
|
|
102
149
|
}
|