@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.
Files changed (63) hide show
  1. package/CHANGELOG.md +62 -0
  2. package/README.md +5 -1
  3. package/docs/README.md +2 -0
  4. package/docs/reference/blocks.json +154 -1
  5. package/docs/reference/config.md +27 -14
  6. package/package.json +1 -1
  7. package/schema/code-forge.schema.json +57 -2
  8. package/schema/outcome.schema.json +45 -0
  9. package/schema/pipeline.schema.json +30 -0
  10. package/scripts/first-use.mjs +8 -0
  11. package/scripts/gen-config-doc.mjs +1 -1
  12. package/skill/references/code.md +15 -0
  13. package/skill/references/ledger.md +13 -1
  14. package/skill/scripts/forge +1 -1
  15. package/src/cli/block.mjs +146 -30
  16. package/src/cli/context.mjs +17 -7
  17. package/src/cli/gates.mjs +76 -10
  18. package/src/cli/help.mjs +4 -0
  19. package/src/cli/outcome.mjs +87 -0
  20. package/src/cli/pipeline.mjs +61 -0
  21. package/src/cli/proof.mjs +10 -7
  22. package/src/cli/report.mjs +53 -6
  23. package/src/cli/review-file.mjs +168 -38
  24. package/src/cli/run.mjs +3 -2
  25. package/src/cli/sweep.mjs +28 -0
  26. package/src/cli/validate.mjs +6 -0
  27. package/src/config/schema-compile.mjs +6 -4
  28. package/src/config/validate.mjs +83 -1
  29. package/src/context/pack.mjs +53 -3
  30. package/src/doctor/local.mjs +22 -4
  31. package/src/engines/builders/codex.mjs +27 -7
  32. package/src/engines/builders/grok.mjs +11 -5
  33. package/src/engines/codex-home.mjs +18 -15
  34. package/src/engines/sentinel.mjs +11 -0
  35. package/src/gates/detect.mjs +6 -3
  36. package/src/gates/run.mjs +295 -27
  37. package/src/ledger/export.mjs +1 -1
  38. package/src/ledger/report.mjs +143 -5
  39. package/src/outcome/outcome.mjs +108 -0
  40. package/src/pipeline/pipeline.mjs +112 -0
  41. package/src/proof/red-green.mjs +41 -13
  42. package/src/proof/runners.mjs +28 -4
  43. package/src/proof/worktree.mjs +25 -15
  44. package/src/review/agent-waiver.mjs +18 -4
  45. package/src/review/engine.mjs +25 -5
  46. package/src/review/gate-check.mjs +39 -1
  47. package/src/review/lenses/full.md +1 -0
  48. package/src/review/lenses/quick.md +1 -0
  49. package/src/review/packet.mjs +34 -6
  50. package/src/review/related.mjs +112 -0
  51. package/src/review/reopen.mjs +4 -3
  52. package/src/review/session-retry.mjs +59 -0
  53. package/src/session/facts.mjs +18 -4
  54. package/src/session/spawn.mjs +54 -3
  55. package/src/state/block.mjs +101 -34
  56. package/src/state/config-snapshot.mjs +13 -3
  57. package/src/state/run.mjs +3 -2
  58. package/src/util/exec.mjs +48 -31
  59. package/src/util/reaper.mjs +238 -8
  60. package/src/worker/engine.mjs +7 -3
  61. package/src/worker/loop.mjs +67 -14
  62. package/src/worker/queue.mjs +106 -23
  63. 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 timeoutMs = timeoutFlag.value !== undefined ? Number(timeoutFlag.value) : undefined;
236
- if (timeoutMs !== undefined && !(Number.isFinite(timeoutMs) && timeoutMs > 0)) {
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, ...(timeoutMs ? { timeoutMs } : {}) });
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, ...(timeoutMs ? { timeoutMs } : {}) });
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
- ...(result.reason === 'flaky-test' ? { reason: 'flaky-test' } : {}),
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
- // B87: the runner comes from the run snapshot only, never the live file; no entry matches ⇒
286
- // the built-in node:test command, or the refusal
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;
@@ -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 15 sections as JSON files. The
4
- * terminal output ends with the spend per run (B33) and the mode of each run (B59: brief or plan);
5
- * both are text only, never in `--json` or `--export`. The 14th section, `outside_review` (B66), is
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
- const body = name === 'outside_review' ? outsideReviewLines(data) : `${JSON.stringify(data, null, 2)}\n`;
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 15 sections. Unknown-price
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 ?? '(none)'}: $${r.usd.toFixed(4)} · ${r.sessions} session(s)${unknown(r.unknown)}`);
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
  }