@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/gates/run.mjs CHANGED
@@ -6,6 +6,11 @@
6
6
  * stops early.
7
7
  */
8
8
 
9
+ import { createHash } from 'node:crypto';
10
+ import { constants } from 'node:fs';
11
+ import { lstat, open, readdir, realpath } from 'node:fs/promises';
12
+ import path from 'node:path';
13
+ import { customGateMessages } from '../config/validate.mjs';
9
14
  import { exec } from '../util/exec.mjs';
10
15
  import { registerSecret } from '../util/redact.mjs';
11
16
  import { CHANGED_FILES_TOKEN } from '../util/gate-token.mjs';
@@ -16,6 +21,9 @@ import { NO_BLOCK_SCOPE, TEST_FILES_TOKEN, changedTestFiles, envOf, expandArgv,
16
21
  /** @typedef {import('./detect.mjs').DetectedGates} DetectedGates */
17
22
  /** @typedef {import('./expand.mjs').TestScope} TestScope */
18
23
  /** @typedef {{n: number, files: string[], failed: boolean}} Repeats */
24
+ /** @typedef {{name: string, argv: string[], env?: Record<string, string>, evidence?: string[]}} CustomGate */
25
+ /** @typedef {{path: string, bytes: number, sha256: string}} Evidence */
26
+ /** @typedef {{kind: string, names: string[]}} KindGates */
19
27
  /**
20
28
  * Internal block context carried with the gate plan through the proof lock, so repeats run
21
29
  * inside the same slot as the initial commands.
@@ -24,17 +32,22 @@ import { NO_BLOCK_SCOPE, TEST_FILES_TOKEN, changedTestFiles, envOf, expandArgv,
24
32
 
25
33
  /**
26
34
  * @typedef {object} GateResult
27
- * @property {'test'|'lint'|'types'|'format'} gate
35
+ * @property {string} gate
36
+ * @property {number} duration_ms - wall time including repeats; zero for skipped gates.
37
+ * @property {true} [custom]
38
+ * @property {Evidence[]} [evidence]
39
+ * @property {string[]} [missing]
28
40
  * @property {string[] | null} argv
29
- * @property {boolean} skipped - true when the gate has no configured command (argv was null).
41
+ * @property {boolean} skipped - true when unconfigured or excluded by the block's kind set.
30
42
  * @property {boolean} ok - true when skipped, or when the command exited with code 0 (and, for a
31
43
  * `failOnStdout` gate, its stdout was empty).
32
44
  * @property {number | null} code
33
45
  * @property {string} stdout
34
46
  * @property {string} stderr
47
+ * @property {string} [message] - termination diagnostic with the last lines of both streams.
35
48
  * @property {'related'|'full'|'changed'} [scope] - only on a gate with a scope token.
36
49
  * @property {number} [test_count] - B60: with `scope: related`, how many test files it ran.
37
- * @property {'threshold'|'no_tests'|'no_baseline'|'unpaired'|'no_changes'|'flaky-test'} [reason] - fallback or repeat failure.
50
+ * @property {'threshold'|'no_tests'|'no_baseline'|'unpaired'|'no_changes'|'flaky-test'|'timeout'|'prompt'|'spawn-error'|'terminated'|NodeJS.Signals|`not in gates.kinds.${string}`} [reason] - fallback, exclusion or gate failure.
38
51
  * @property {Repeats} [repeats]
39
52
  * @property {string[]} [env_keys] - B60: only on a gate given with a non-empty `env`: the variable
40
53
  * NAMES, sorted (the values are never in a result).
@@ -43,6 +56,177 @@ import { NO_BLOCK_SCOPE, TEST_FILES_TOKEN, changedTestFiles, envOf, expandArgv,
43
56
  /** Fixed run order — always all four, regardless of an earlier one's result. */
44
57
  const GATE_ORDER = /** @type {const} */ (['test', 'lint', 'types', 'format']);
45
58
 
59
+ /** Leave room below typical ARG_MAX limits for the inherited environment and spawn overhead. */
60
+ const FORMAT_ARGV_BUDGET_BYTES = 100 * 1024;
61
+
62
+ /** Repeat the executable and flags in every batch; count UTF-8 bytes and NUL terminators.
63
+ * @param {string[]} prefix @param {string[]} files @param {number} budget
64
+ * @returns {string[][]}
65
+ */
66
+ function formatBatches(prefix, files, budget) {
67
+ if (!Number.isSafeInteger(budget) || budget <= 0) throw new TypeError('format: argv byte budget must be a positive safe integer');
68
+ const prefixBytes = prefix.reduce((sum, arg) => sum + Buffer.byteLength(arg) + 1, 0);
69
+ const batches = [];
70
+ let batch = prefix.slice();
71
+ let bytes = prefixBytes;
72
+ for (const file of files) {
73
+ const size = Buffer.byteLength(file) + 1;
74
+ if (prefixBytes + size > budget) throw new RangeError(`format: command and file exceed argv byte budget: ${file}`);
75
+ if (bytes + size > budget) {
76
+ batches.push(batch);
77
+ batch = prefix.slice();
78
+ bytes = prefixBytes;
79
+ }
80
+ batch.push(file);
81
+ bytes += size;
82
+ }
83
+ batches.push(batch);
84
+ return batches;
85
+ }
86
+
87
+ /** @param {string} stream @returns {string} */
88
+ function outputTail(stream) {
89
+ const tail = stream.trimEnd().split(/\r?\n/).slice(-10).join('\n');
90
+ return tail ? `${tail}\n` : '';
91
+ }
92
+
93
+ /** Existing supported files only, never a directory or glob for the formatter to traverse.
94
+ * @param {string} cwd @param {'pint'|'prettier'} formatter
95
+ * @param {TestScope | import('./expand.mjs').ChangedScope} scope
96
+ * @returns {Promise<string[]>}
97
+ */
98
+ async function formatFiles(cwd, formatter, scope) {
99
+ const changed = scope.scope === 'changed';
100
+ const env = Object.fromEntries(Object.entries(process.env).filter(([key]) => !key.startsWith('GIT_')));
101
+ const opts = { cwd, env, timeoutMs: 30000 };
102
+ const listed = await exec(['git', 'ls-files', '-z', '--cached', ...(changed ? ['--others'] : []), '--exclude-standard'], opts);
103
+ if (listed.result !== 'ok') throw new Error(`format: cannot list Git files: ${listed.stderr || listed.error || listed.signal || 'git failed'}`);
104
+ const wanted = changed ? new Set(scope.changedFiles) : null;
105
+ const files = [...new Set(listed.stdout.split('\0').filter((file) => file && (!wanted || wanted.has(file))))].sort();
106
+ if (files.length === 0) return [];
107
+ // --no-index also excludes force-added ignored paths. A cached path alone is not proof
108
+ // that the current ignore rules permit it.
109
+ const ignored = await exec(['git', 'check-ignore', '--no-index', '-z', '--stdin'], {
110
+ ...opts, input: `${files.join('\0')}\0`, okExitCodes: [0, 1],
111
+ });
112
+ if (ignored.result !== 'ok') throw new Error(`format: cannot check Git ignores: ${ignored.stderr || ignored.error || ignored.signal || 'git failed'}`);
113
+ const excluded = new Set(ignored.stdout.split('\0'));
114
+ const root = await realpath(cwd);
115
+ const selected = [];
116
+ for (const file of files) {
117
+ if (excluded.has(file)) continue;
118
+ const supported = formatter === 'pint' ? /\.php$/i.test(file)
119
+ : /\.(?:[cm]?[jt]sx?|json5?|jsonc|css|scss|less|html?|vue|md|mdx|markdown|ya?ml|graphql|gql|hbs|handlebars|mustache|mjml)$/i.test(file)
120
+ || ['.prettierrc', '.babelrc', '.eslintrc', '.stylelintrc'].includes(path.basename(file));
121
+ if (!supported) continue;
122
+ try {
123
+ const absolute = path.resolve(root, file);
124
+ if (!(await lstat(absolute)).isFile() || await realpath(absolute) !== absolute) continue;
125
+ } catch (error) {
126
+ if (['ENOENT', 'ENOTDIR'].includes(error.code)) continue;
127
+ throw error;
128
+ }
129
+ // Leading dashes cannot become options, and existing paths are not treated as globs.
130
+ selected.push(`./${file}`);
131
+ }
132
+ return selected;
133
+ }
134
+
135
+ /** Unanswered prompt suffixes; inspect only each stream's last non-empty line. */
136
+ export const UNANSWERED_PROMPT_PATTERN = /(?:\?|\(y(?:\/n)?\)|\[y\/n\]|yes\/no)\s*$/i;
137
+
138
+ /** Simple globs only: * stays in one segment; ** can cross segments.
139
+ * @param {string} pattern @returns {RegExp}
140
+ */
141
+ function evidenceMatcher(pattern) {
142
+ const normalized = pattern.split('/').filter((part) => part !== '.' && part !== '').join('/');
143
+ let source = '';
144
+ for (let i = 0; i < normalized.length; i++) {
145
+ const char = normalized[i];
146
+ if (char === '*' && normalized[i + 1] === '*') {
147
+ i++;
148
+ if (normalized[i + 1] === '/') { source += '(?:.*/)?'; i++; }
149
+ else source += '.*';
150
+ } else if (char === '*') source += '[^/]*';
151
+ else source += char.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
152
+ }
153
+ return new RegExp(`^${source}$`);
154
+ }
155
+
156
+ /** Missing, inaccessible or replaced entries are not evidence.
157
+ * @param {NodeJS.ErrnoException} error @returns {boolean}
158
+ */
159
+ function evidenceUnavailable(error) {
160
+ return ['ENOENT', 'ENOTDIR', 'EACCES', 'ELOOP'].includes(error.code);
161
+ }
162
+
163
+ /** Never traverse symlinks. Hash at most 200 unique files, without retaining their content.
164
+ * @param {string} cwd @param {string[]} patterns
165
+ * @returns {Promise<{evidence: Evidence[], missing?: string[]}>}
166
+ */
167
+ async function collectEvidence(cwd, patterns) {
168
+ if (patterns.length === 0) return { evidence: [] };
169
+ let root;
170
+ try { root = await realpath(cwd); }
171
+ catch (error) {
172
+ if (evidenceUnavailable(error)) return { evidence: [], missing: patterns };
173
+ throw error;
174
+ }
175
+ const matchers = patterns.map(evidenceMatcher);
176
+ /** @type {Set<string>} */
177
+ const matched = new Set();
178
+ /** @param {string} dir @returns {Promise<void>} */
179
+ async function walk(dir) {
180
+ let entries;
181
+ try { entries = await readdir(path.join(root, dir), { withFileTypes: true }); }
182
+ catch (error) {
183
+ if (evidenceUnavailable(error)) return;
184
+ throw error;
185
+ }
186
+ for (const entry of entries) {
187
+ const file = dir ? `${dir}/${entry.name}` : entry.name;
188
+ if (entry.isSymbolicLink()) continue;
189
+ // Also check the resolved path before descending/opening, in case a parent changed.
190
+ let resolved;
191
+ try { resolved = await realpath(path.join(root, file)); }
192
+ catch (error) { if (evidenceUnavailable(error)) continue; throw error; }
193
+ const relative = path.relative(root, resolved);
194
+ if (relative === '..' || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative)) continue;
195
+ if (entry.isDirectory()) await walk(file);
196
+ else if (entry.isFile()) {
197
+ if (matchers.some((matcher) => matcher.test(file))) matched.add(file);
198
+ }
199
+ }
200
+ }
201
+ await walk('');
202
+ /** @type {Evidence[]} */
203
+ const evidence = [];
204
+ for (const file of [...matched].sort().slice(0, 200)) {
205
+ try {
206
+ // Recheck parents at the time of hashing, rather than relying on the earlier walk.
207
+ const resolved = await realpath(path.join(root, file));
208
+ const relative = path.relative(root, resolved);
209
+ if (relative === '..' || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative)) { matched.delete(file); continue; }
210
+ const handle = await open(resolved, constants.O_RDONLY | constants.O_NOFOLLOW);
211
+ try {
212
+ if (!(await handle.stat()).isFile()) { matched.delete(file); continue; }
213
+ const hash = createHash('sha256');
214
+ let bytes = 0;
215
+ for await (const chunk of handle.createReadStream({ autoClose: false })) {
216
+ bytes += chunk.length;
217
+ hash.update(chunk);
218
+ }
219
+ evidence.push({ path: file, bytes, sha256: hash.digest('hex') });
220
+ } finally { await handle.close(); }
221
+ } catch (error) {
222
+ if (!evidenceUnavailable(error)) throw error;
223
+ matched.delete(file);
224
+ }
225
+ }
226
+ const missing = patterns.filter((_, i) => ![...matched].some((file) => matchers[i].test(file)));
227
+ return { evidence, ...(missing.length ? { missing } : {}) };
228
+ }
229
+
46
230
  /**
47
231
  * A gate command is either a plain argv array or `{argv, failOnStdout?, env?}` (detect.mjs's
48
232
  * `GateCommand`: `failOnStdout` for `gofmt -l`, which always exits 0; B60 `env` from the config).
@@ -55,14 +239,47 @@ function normalizeCommand(command) {
55
239
  return { argv: command.argv, failOnStdout: command.failOnStdout === true, env: envOf(command) }; // `env: {}` is no env
56
240
  }
57
241
 
242
+ /** Interpret timeouts and unanswered prompts for every gate, including repeats.
243
+ * @param {import('../util/exec.mjs').ExecResult} res
244
+ * @param {number} timeoutMs
245
+ * @param {boolean} [failOnStdout]
246
+ * @returns {Pick<GateResult, 'ok'|'code'|'stdout'|'stderr'|'reason'|'message'>}
247
+ */
248
+ function gateOutcome(res, timeoutMs, failOnStdout = false) {
249
+ const prompt = res.result === 'ok' && [res.stdout, res.stderr].some((stream) =>
250
+ UNANSWERED_PROMPT_PATTERN.test(stream.trimEnd().split(/\r?\n/).at(-1)));
251
+ const reason = res.timedOut ? 'timeout'
252
+ : res.code === null ? res.signal ?? (res.error ? 'spawn-error' : 'terminated')
253
+ : prompt ? 'prompt' : undefined;
254
+ const diagnostic = res.timedOut
255
+ ? `gate timeout after ${timeoutMs / 1000} seconds; raise gates.timeout_s (or --timeout-ms)`
256
+ : res.code === null ? `gate terminated: ${res.signal ?? res.error ?? 'no exit status (no signal reported)'}`
257
+ : prompt ? 'gate ended on an interactive prompt; install the tool or make the command non-interactive' : '';
258
+ const tails = ['stdout', 'stderr'].flatMap((stream) => {
259
+ const tail = res[stream].trimEnd().split(/\r?\n/).slice(-10).join('\n');
260
+ return tail ? [`${stream} tail:\n${tail}`] : [];
261
+ });
262
+ const message = [diagnostic, ...tails].join('\n');
263
+ return {
264
+ ok: res.result === 'ok' && !reason && (!failOnStdout || res.stdout.trim().length === 0),
265
+ code: res.code,
266
+ stdout: res.stdout,
267
+ stderr: diagnostic
268
+ ? `${res.stderr}${res.stderr && !res.stderr.endsWith('\n') ? '\n' : ''}${diagnostic}\n`
269
+ : res.stderr,
270
+ ...(reason ? { reason } : {}),
271
+ ...(diagnostic ? { message } : {}),
272
+ };
273
+ }
274
+
58
275
  /**
59
276
  * B60: `opts.scope` is the block's {@link TestScope} (`testScope`, gates/expand.mjs); a gate argv
60
277
  * item that is exactly `{test_files}` expands to its files, or is dropped for a full run. Left out,
61
278
  * no test file is known (`scope: full, reason: no_tests`). A gate with `env` runs with those
62
- * variables added to the inherited environment; a gate with neither runs exactly as before B60.
63
- * @param {{test?: Gate, lint?: Gate, types?: Gate, format?: Gate, repeatNewTests?: RepeatNewTests, lightTestFiles?: string[]}} gates
64
- * @param {{cwd: string, timeoutMs?: number, scope?: TestScope | import('./expand.mjs').ChangedScope}} opts
65
- * @returns {Promise<{results: GateResult[], allOk: boolean, repeats?: Repeats}>}
279
+ * variables added to the inherited environment, overriding the CI=1/npm_config_yes=false defaults.
280
+ * @param {{test?: Gate, lint?: Gate, types?: Gate, format?: Gate, custom?: CustomGate[], repeatNewTests?: RepeatNewTests, lightTestFiles?: string[], kindGates?: KindGates}} gates
281
+ * @param {{cwd: string, timeoutMs?: number, scope?: TestScope | import('./expand.mjs').ChangedScope, formatArgvBudgetBytes?: number}} opts - formatArgvBudgetBytes overrides the detected formatter batch budget (default 100 KiB).
282
+ * @returns {Promise<{results: GateResult[], allOk: boolean, duration_ms: number, repeats?: Repeats}>}
66
283
  */
67
284
  export async function runGates(gates, opts) {
68
285
  if (!gates || typeof gates !== 'object') {
@@ -73,48 +290,80 @@ export async function runGates(gates, opts) {
73
290
  if (typeof cwd !== 'string' || cwd.length === 0) {
74
291
  throw new TypeError('runGates: opts.cwd must be a non-empty string');
75
292
  }
293
+ const customErrors = customGateMessages(gates.custom);
294
+ if (customErrors.length > 0) throw new TypeError(customErrors.join('; '));
295
+ const kindSet = gates.lightTestFiles ? undefined : gates.kindGates;
296
+ const excluded = (/** @type {string} */ name) => kindSet && !kindSet.names.includes(name);
297
+ const skipReason = () => /** @type {const} */ (`not in gates.kinds.${kindSet.kind}`);
76
298
 
77
299
  // B60: gate env is not a secret store. Its values are never put in a result (only `env_keys`);
78
300
  // a value of 8+ characters is also masked in the child's own output (a shorter one would mask
79
301
  // unrelated text — `MODE=test` vs `npm test`)
80
302
  for (const value of maskableEnvValues(gates)) registerSecret(value);
303
+ for (const gate of gates.custom ?? []) {
304
+ for (const value of maskableEnvValues({ gate })) registerSecret(value);
305
+ }
81
306
  // Every gate is normalized and expanded BEFORE any runs, so a bad gate throws with nothing run.
82
307
  // B60: config validation refuses a gate whose only item is {test_files}; the empty-argv check
83
308
  // is the guard for one that reaches here anyway: it names the gate, not an opaque spawn error.
84
- const planned = GATE_ORDER.map((gate) => {
309
+ const planned = [];
310
+ for (const gate of GATE_ORDER) {
85
311
  const command = gates[gate] ?? null;
86
- if (command === null) return { gate, command: null };
312
+ if (command === null || excluded(gate)) { planned.push({ gate, command: null }); continue; }
87
313
  const normalized = normalizeCommand(command);
88
314
  if (normalized.argv[0] === CHANGED_FILES_TOKEN) throw new TypeError(`gates.${gate}: '{changed_files}' cannot be the command (argv[0])`);
89
- const { argv, scoped } = expandArgv(normalized.argv, scope);
315
+ let { argv, scoped } = expandArgv(normalized.argv, scope);
90
316
  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`);
91
- return { gate, command: { argv, originalArgv: normalized.argv, scoped, failOnStdout: normalized.failOnStdout, env: normalized.env } };
92
- });
317
+ const formatter = gate === 'format' && !Array.isArray(command) ? command.formatter : undefined;
318
+ let batches;
319
+ if (formatter) {
320
+ const files = await formatFiles(cwd, formatter, scope);
321
+ if (files.length === 0) { planned.push({ gate, command: null, noFiles: true }); continue; }
322
+ batches = formatBatches(argv, files, opts.formatArgvBudgetBytes ?? FORMAT_ARGV_BUDGET_BYTES);
323
+ argv = argv.concat(files);
324
+ }
325
+ planned.push({ gate, command: { argv, batches, originalArgv: normalized.argv, scoped, failOnStdout: normalized.failOnStdout, env: normalized.env } });
326
+ }
93
327
  /** @type {GateResult[]} */
94
328
  const results = [];
95
- for (const { gate, command } of planned) {
329
+ for (const { gate, command, noFiles } of planned) {
96
330
  if (command === null) {
97
- results.push({ gate, argv: null, skipped: true, ok: true, code: null, stdout: '', stderr: '' });
331
+ results.push({ gate, duration_ms: 0, argv: null, skipped: true, ok: true, code: null, stdout: '', stderr: '',
332
+ ...(gates[gate] != null && excluded(gate) ? { reason: skipReason() } : {}),
333
+ ...(noFiles ? { reason: 'no_changes' } : {}),
334
+ });
98
335
  continue;
99
336
  }
100
- const { argv, scoped, failOnStdout, env } = command;
337
+ const { argv, batches = [argv], scoped, failOnStdout, env } = command;
101
338
  // Each command is run and its exit status read on its own — never `&&`-chained with the
102
339
  // next, so a red gate is reported red without swallowing the gates that follow it.
103
- const res = await exec(argv, env ? { cwd, timeoutMs, env: { ...process.env, ...env } } : { cwd, timeoutMs });
104
- const ok = res.result === 'ok' && (!failOnStdout || res.stdout.trim().length === 0);
340
+ const execOpts = { cwd, timeoutMs, env: { ...process.env, CI: '1', npm_config_yes: 'false', ...env } };
341
+ const started = performance.now();
342
+ const outcomes = [];
343
+ for (const batch of batches) {
344
+ const res = await exec(batch, execOpts);
345
+ const batchOutcome = gateOutcome(res, timeoutMs, failOnStdout);
346
+ outcomes.push({ ...batchOutcome, ...(batches.length > 1 ? {
347
+ stdout: outputTail(batchOutcome.stdout), stderr: outputTail(batchOutcome.stderr),
348
+ } : {}) });
349
+ }
350
+ // Retain the first failure's status and diagnostic even if later batches pass.
351
+ const outcome = {
352
+ ...(outcomes.find((entry) => !entry.ok) ?? outcomes[0]),
353
+ stdout: outcomes.map((entry) => entry.stdout).join(''),
354
+ stderr: outcomes.map((entry) => entry.stderr).join(''),
355
+ };
105
356
  const result = /** @type {GateResult} */ ({
106
357
  gate,
358
+ duration_ms: 0,
107
359
  argv,
108
360
  skipped: false,
109
- ok,
110
- code: res.code,
111
- stdout: res.stdout,
112
- stderr: res.stderr,
113
361
  ...(scoped ? scopeFields(scope) : {}),
362
+ ...outcome,
114
363
  ...(env ? { env_keys: Object.keys(env).sort() } : {}),
115
364
  });
116
365
  const repeat = gates.repeatNewTests;
117
- if (gate === 'test' && ok && repeat) {
366
+ if (gate === 'test' && outcome.ok && repeat) {
118
367
  if (!command.originalArgv.includes(TEST_FILES_TOKEN)) {
119
368
  repeat.onSkip?.('repeat_new_tests: skipped (gates.test has no {test_files})');
120
369
  } else {
@@ -123,19 +372,38 @@ export async function runGates(gates, opts) {
123
372
  result.repeats = { n: repeat.n, files, failed: false };
124
373
  const repeatArgv = expandArgv(command.originalArgv, { scope: 'related', files }).argv;
125
374
  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);
375
+ const repeated = await exec(repeatArgv, execOpts);
376
+ const repeatedOutcome = gateOutcome(repeated, timeoutMs, failOnStdout);
128
377
  // Keep the first failed run's output, even when a later repeat passes.
129
- if (!passed && !result.repeats.failed) {
378
+ if (!repeatedOutcome.ok && !result.repeats.failed) {
130
379
  result.repeats.failed = true;
131
- Object.assign(result, { ok: false, reason: 'flaky-test', argv: repeatArgv, code: repeated.code, stdout: repeated.stdout, stderr: repeated.stderr });
380
+ Object.assign(result, { reason: 'flaky-test', argv: repeatArgv, ...repeatedOutcome });
132
381
  }
133
382
  }
134
383
  }
135
384
  }
136
385
  }
386
+ result.duration_ms = Math.round(performance.now() - started);
137
387
  results.push(result);
138
388
  }
389
+ for (const gate of gates.custom ?? []) {
390
+ if (excluded(gate.name)) {
391
+ results.push({ gate: gate.name, duration_ms: 0, custom: true, argv: null, skipped: true, ok: true, code: null,
392
+ stdout: '', stderr: '', reason: skipReason(), evidence: [],
393
+ });
394
+ continue;
395
+ }
396
+ const { argv, env } = normalizeCommand(gate);
397
+ const started = performance.now();
398
+ const res = await exec(argv, { cwd, timeoutMs, env: { ...process.env, CI: '1', npm_config_yes: 'false', ...env } });
399
+ const evidence = await collectEvidence(cwd, gate.evidence ?? []);
400
+ results.push({ gate: gate.name, custom: true, argv, skipped: false,
401
+ duration_ms: Math.round(performance.now() - started),
402
+ ...gateOutcome(res, timeoutMs),
403
+ ...(env ? { env_keys: Object.keys(env).sort() } : {}),
404
+ ...evidence,
405
+ });
406
+ }
139
407
  const repeats = results.find((r) => r.repeats)?.repeats;
140
- return { results, allOk: results.every((r) => r.ok), ...(repeats ? { repeats } : {}) };
408
+ return { results, allOk: results.every((r) => r.ok), duration_ms: results.reduce((sum, r) => sum + r.duration_ms, 0), ...(repeats ? { repeats } : {}) };
141
409
  }
@@ -1,5 +1,5 @@
1
1
  /**
2
- * `report --export <dir>` — writes one JSON file per report section (plan §6.2; 15 sections since B124b).
2
+ * `report --export <dir>` — writes one JSON file per report section (plan §6.2; 17 sections since B154).
3
3
  */
4
4
 
5
5
  import { mkdir, writeFile } from 'node:fs/promises';
@@ -1,5 +1,5 @@
1
1
  /**
2
- * `code-forge report` (plan §6.2, R4): builds the 15 report sections from a run's ledger rows.
2
+ * `code-forge report` (plan §6.2, R4): builds the report sections from a project's ledger rows.
3
3
  * Pure data, no I/O — terminal rendering + `--export` live in `src/cli/report.mjs`.
4
4
  *
5
5
  * Shadow rows (`row.source === 'shadow'`) are excluded everywhere here (plan §6.3: shadow-sampled
@@ -80,19 +80,42 @@ export function buildRunModes(rows) {
80
80
  return runs.map((run) => ({ run, mode: brief.has(run) ? 'brief' : 'plan' }));
81
81
  }
82
82
 
83
+ /** A full ISO timestamp with a timezone; reject coerced values and impossible calendar dates.
84
+ * @param {unknown} ts @returns {string | null} */
85
+ function timestamp(ts) {
86
+ if (typeof ts !== 'string' || !/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:\d{2})$/.test(ts)
87
+ || !Number.isFinite(Date.parse(ts))) return null;
88
+ const day = ts.slice(0, 10);
89
+ const date = Date.parse(`${day}T00:00:00.000Z`);
90
+ return Number.isFinite(date) && new Date(date).toISOString().slice(0, 10) === day ? ts : null;
91
+ }
92
+
93
+ /** Missing or reversed endpoints cannot establish elapsed time.
94
+ * @param {string | null} start @param {string | null} end @returns {number | null} */
95
+ function elapsed(start, end) {
96
+ if (start === null || end === null) return null;
97
+ const ms = Date.parse(end) - Date.parse(start);
98
+ return ms >= 0 ? ms : null;
99
+ }
100
+
83
101
  /**
84
102
  * Cost per block — completed AND still open (B33: a running total while the block is open):
85
- * review $ / coder $ / S1+S2 $ / facts $ / proof ms, `completed` (code close with no pending human check) and
103
+ * review $ / coder $ / S1+S2 $ / facts $ / proof ms, measured gate/review ms,
104
+ * `completed` (code close with no pending human check) and
86
105
  * `unknownUsdSessions` (spend rows whose price is unknown, counted 0 in the totals). Grouped by
87
106
  * run + block, so a block id reused in a second run is its own entry; a row without a run joins
88
107
  * 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
89
- * once it has a spend row or a `block.close` row.
108
+ * once it has a spend row, a `block.close` row, or is stopped (`stopped: true`, omitted otherwise).
90
109
  */
91
110
  function costPerBlock(rows) {
92
111
  /** @type {Map<string, any>} */
93
112
  const byBlock = new Map();
113
+ /** @type {Map<string, {firstDispatch: string | null, completion: string | null, stopped: string | null, awaiting: string | null, humanWait: number | null}>} */
114
+ const times = new Map();
94
115
  /** @type {Set<string>} run+block keys with at least one spend row */
95
116
  const spent = new Set();
117
+ const gated = new Set();
118
+ const reviewed = new Set();
96
119
  const closedKeys = new Set();
97
120
  // never split one block: a row without a run joins its block's entry when that block id appears
98
121
  // in exactly one run; only a block seen in several runs leaves such a row in a `run: null` entry
@@ -126,18 +149,52 @@ function costPerBlock(rows) {
126
149
  factsUsd: 0,
127
150
  otherUsd: 0,
128
151
  proofTimeMs: 0,
152
+ wallMs: null,
153
+ humanWaitMs: null,
129
154
  totalUsd: 0,
130
155
  unknownUsdSessions: 0,
131
156
  });
157
+ times.set(key, { firstDispatch: null, completion: null, stopped: null, awaiting: null, humanWait: null });
132
158
  }
133
159
  const e = byBlock.get(key);
160
+ const time = times.get(key);
161
+ if (['dispatch', 'block.close', 'block.awaiting_human_check', 'block.human_pass'].includes(row.event)) {
162
+ delete e.stopped;
163
+ time.stopped = null;
164
+ }
134
165
  if (row.event === 'dispatch') {
135
166
  e.level = row.level ?? e.level;
136
167
  e.lane = row.lane ?? e.lane;
168
+ e.completed = false;
137
169
  }
138
170
  if (row.event === 'block.close') { e.completed = true; closedKeys.add(key); }
139
171
  if (row.event === 'block.awaiting_human_check') e.completed = false;
140
172
  if (row.event === 'block.human_pass') e.completed = true;
173
+ if (row.event === 'block.stop') { e.completed = false; e.stopped = true; }
174
+ // Invalid timestamps have no effect on time; lifecycle and spend still count normally.
175
+ const ts = timestamp(row.ts);
176
+ if (ts !== null) {
177
+ if (row.event === 'dispatch') {
178
+ time.firstDispatch ??= ts;
179
+ time.completion = null;
180
+ time.awaiting = null;
181
+ time.humanWait = null;
182
+ }
183
+ if (row.event === 'block.close') {
184
+ time.completion = ts;
185
+ time.humanWait = null;
186
+ }
187
+ if (row.event === 'block.awaiting_human_check') {
188
+ time.awaiting = ts;
189
+ time.completion = null;
190
+ time.humanWait = null;
191
+ }
192
+ if (row.event === 'block.human_pass') {
193
+ time.completion = ts;
194
+ time.humanWait = elapsed(time.awaiting, ts);
195
+ }
196
+ if (row.event === 'block.stop') time.stopped = ts;
197
+ }
141
198
  if (isSpendRow(row)) {
142
199
  spent.add(key);
143
200
  const cost = sessionCostUsd(row);
@@ -153,14 +210,73 @@ function costPerBlock(rows) {
153
210
  }
154
211
  }
155
212
  if (row.event === 'proof' && typeof row.duration_ms === 'number') e.proofTimeMs += row.duration_ms;
213
+ // gate.red is a diagnostic for a command already included in gate.done's total.
214
+ // Leave legacy entries without a gateTimeMs field when no measured run is available.
215
+ if (row.event === 'gate.done' && typeof row.duration_ms === 'number' && Number.isFinite(row.duration_ms) && row.duration_ms >= 0) {
216
+ e.gateTimeMs = (e.gateTimeMs ?? 0) + row.duration_ms;
217
+ gated.add(key);
218
+ }
219
+ if (row.event === 'review.result' && typeof row.duration_ms === 'number' && Number.isFinite(row.duration_ms) && row.duration_ms >= 0) {
220
+ e.reviewTimeMs = (e.reviewTimeMs ?? 0) + row.duration_ms;
221
+ reviewed.add(key);
222
+ }
156
223
  }
157
- const out = [...byBlock.entries()].filter(([key, e]) => e.completed || spent.has(key) || closedKeys.has(key)).map(([, e]) => e);
224
+ const out = [...byBlock.entries()].filter(([key, e]) => e.completed || e.stopped || spent.has(key) || gated.has(key) || reviewed.has(key) || closedKeys.has(key)).map(([key, e]) => {
225
+ const time = times.get(key);
226
+ if (e.completed) {
227
+ e.wallMs = elapsed(time.firstDispatch, time.completion);
228
+ e.humanWaitMs = time.humanWait;
229
+ }
230
+ if (e.stopped) e.wallMs = elapsed(time.firstDispatch, time.stopped);
231
+ return e;
232
+ });
158
233
  for (const e of out) {
159
234
  for (const k of ['reviewUsd', 'coderUsd', 's1s2Usd', 'factsUsd', 'otherUsd', 'totalUsd']) e[k] = Math.round(e[k] * 10000) / 10000;
160
235
  }
161
236
  return out;
162
237
  }
163
238
 
239
+ /** Per-run wall time, spend and distinct block counts, plus the ledger/site total.
240
+ * Rows and costs have already had shadow rows removed. Untimed runs contribute 0 to total wallMs.
241
+ * Rows without string run ids go to outside_runs; total.usd includes outside_runs.usd.
242
+ * @param {Array<Record<string, any>>} rows @param {any[]} costs */
243
+ function runTime(rows, costs) {
244
+ /** @type {Map<string, Array<Record<string, any>>>} */
245
+ const groups = new Map();
246
+ const outside = [];
247
+ for (const row of rows) {
248
+ if (typeof row.run !== 'string') { outside.push(row); continue; }
249
+ const run = row.run;
250
+ if (!groups.has(run)) groups.set(run, []);
251
+ groups.get(run).push(row);
252
+ }
253
+ const spend = new Map(spendByRun(rows).map((r) => [r.run, r.usd]));
254
+ const runs = [...groups].map(([run, list]) => {
255
+ const timed = list.filter((r) => timestamp(r.ts) !== null);
256
+ const started = timed.find((r) => r.event === 'run.start')?.ts ?? timed[0]?.ts ?? null;
257
+ const ended = timed.filter((r) => r.event === 'run.end').at(-1)?.ts ?? null;
258
+ const open = ended === null;
259
+ return {
260
+ run, started, ended,
261
+ wallMs: elapsed(started, open ? timed.at(-1)?.ts ?? null : ended),
262
+ usd: spend.get(run) ?? 0,
263
+ blocks_closed: costs.filter((b) => b.run === run && b.completed).length,
264
+ blocks_stopped: new Set(list.filter((r) => r.event === 'block.stop' && r.block).map((r) => r.block)).size,
265
+ ...(open ? { open: true } : {}),
266
+ };
267
+ });
268
+ return {
269
+ runs,
270
+ ...(outside.length ? { outside_runs: { usd: sumSpend(outside).usd, rows: outside.length } } : {}),
271
+ total: {
272
+ runs: runs.length,
273
+ wallMs: runs.reduce((sum, r) => sum + (r.wallMs ?? 0), 0),
274
+ usd: sumSpend(rows).usd,
275
+ blocks_closed: runs.reduce((sum, r) => sum + r.blocks_closed, 0),
276
+ },
277
+ };
278
+ }
279
+
164
280
  const laneDistribution = (rows) =>
165
281
  rows.filter((r) => r.event === 'dispatch').map((r) => ({ block: r.block, lanePicked: r.lane ?? null, levelOpened: r.level ?? null }));
166
282
 
@@ -274,8 +390,26 @@ const runStopCounts = (rows) => ({
274
390
  noEngineStops: rows.filter((r) => r.event === 'run.stop' && r.reason === 'no_engine').length,
275
391
  });
276
392
 
393
+ /** Specialist deliverables are separate from calibration's `outcome` events.
394
+ * @param {import('./write.mjs').LedgerRow[]} rows
395
+ */
396
+ function specialistOutcomes(rows) {
397
+ const outcomes = rows.filter((r) => r.event === 'outcome.recorded').map((r) => ({
398
+ skill: r.skill, step: r.step ?? null, status: r.status,
399
+ cost_usd: r.cost_usd ?? null, minutes: r.minutes ?? null,
400
+ }));
401
+ return {
402
+ outcomes,
403
+ total: {
404
+ count: outcomes.length,
405
+ cost_usd: outcomes.reduce((sum, r) => sum + (r.cost_usd ?? 0), 0),
406
+ minutes: outcomes.reduce((sum, r) => sum + (r.minutes ?? 0), 0),
407
+ },
408
+ };
409
+ }
410
+
277
411
  /** The exact, ordered set of section keys `buildReport` produces (plan §6.2; B66 appended
278
- * `outside_review`, then B124b appended `agent_waivers` — append new sections, never reorder) —
412
+ * `outside_review`, B124b `agent_waivers`, B147 `run_time`, B154 `specialist_outcomes` — append new sections, never reorder) —
279
413
  * exported so tests (and `--export`) can assert against ONE source of truth instead of a copy. */
280
414
  export const SECTION_NAMES = Object.freeze([
281
415
  'cost_per_block',
@@ -293,6 +427,8 @@ export const SECTION_NAMES = Object.freeze([
293
427
  'run_stop_counts',
294
428
  'outside_review',
295
429
  'agent_waivers',
430
+ 'run_time',
431
+ 'specialist_outcomes',
296
432
  ]);
297
433
 
298
434
  /**
@@ -325,6 +461,8 @@ export function buildReport(rows) {
325
461
  unwaived: r.slice(i + 1).some((undo) => undo.event === 'review.unwaived' && undo.by === 'human'
326
462
  && undo.run === row.run && undo.block === row.block && undo.file === row.file && undo.finding === row.finding) }]
327
463
  : []),
464
+ run_time: (/** @type {any[]} */ r) => runTime(r, costs),
465
+ specialist_outcomes: specialistOutcomes,
328
466
  };
329
467
  /** @type {Record<string, any>} */
330
468
  const sections = {};