@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
@@ -67,30 +67,85 @@
67
67
  * needs its own review.
68
68
  */
69
69
 
70
- import { readFileSync, realpathSync, rmSync } from 'node:fs';
70
+ import { lstatSync, readdirSync, readFileSync, realpathSync, rmSync, statSync } from 'node:fs';
71
71
  import path from 'node:path';
72
- import { answersTicketId, assertAnswers, blockTickets, deliverBlockFindings, dropDoneIf, enqueue, enqueueAnswers, isDone, liveWorker, MAX_ANSWERS, queueDir, resolveTicket } from '../worker/queue.mjs';
72
+ import { answersTicketId, assertAnswers, blockTickets, deliverBlockFindings, dropDoneIf, enqueue, enqueueAnswers, enqueueHash, isDone, liveWorker, MAX_ANSWERS, queueDir, resolveHashTicket, resolveTicket, reviewExclusionFor } from '../worker/queue.mjs';
73
73
  import { assertBlockId, assertInsideRoot, assertTicketId, contentHash, mainRootOf, normalizeRequestPath, repoRootOf, WorkerError } from '../worker/ticket.mjs';
74
- import { blockFileSet, namedOnHash } from '../review/gate-check.mjs';
74
+ import { changedFiles, namedOnHash } from '../review/gate-check.mjs';
75
75
  import { STOP_EXITS } from '../review/stop-exits.mjs';
76
76
  import { budgetFor } from '../review/packet.mjs';
77
77
  import { ownsFile } from '../state/registry.mjs';
78
- import { readAllRows } from '../ledger/write.mjs';
78
+ import { appendRow, readAllRows } from '../ledger/write.mjs';
79
79
  import { parseFlags } from '../state/cli-args.mjs';
80
80
  import { StateError } from '../state/paths.mjs';
81
81
  import { assertRunId } from '../state/paths.mjs';
82
82
  import { readRun } from '../state/run.mjs';
83
+ import { rebaseBlock } from '../state/block.mjs';
83
84
  import { configInForce } from '../state/config-snapshot.mjs';
85
+ import { reviewExcludeRule } from '../config/validate.mjs';
84
86
  import { blockTree } from '../proof/worktree.mjs';
85
87
  import { writeSafe } from '../util/redact.mjs';
86
88
 
87
89
  const USAGE =
88
- 'usage: code-forge review-file <path> --block <id> [--run <id>] [--fresh] | review-file --wait <ticket> [--max <seconds>s]\n' +
90
+ 'usage: code-forge review-file [--cwd <dir>] <path> --block <id> [--run <id>] [--fresh] | review-file --wait <ticket> [--max <seconds>s]\n' +
89
91
  ' code-forge review-file --wait-block <id> --run <id> [--max <seconds>s]\n' +
90
- ' code-forge review-file <answering file> --block <id> --answers <id,id,…> --of <source> [--run <id>]\n';
92
+ ' code-forge review-file --rebase-check --block <id> --run <id>\n' +
93
+ ' code-forge review-file <answering file> --block <id> --answers <id,id,…> --of <source> [--run <id>]\n' +
94
+ ' --cwd <dir> applies to queueing and both wait modes\n';
91
95
 
92
96
  export const DEFAULT_WAIT_MS = 90_000;
93
97
  const POLL_MS = 200;
98
+ const MAX_IDLE_MS = 5000;
99
+
100
+ /** Metadata only: never open file content during an idle poll.
101
+ * @param {string} file @returns {string}
102
+ */
103
+ function fileStamp(file) {
104
+ try {
105
+ const s = lstatSync(file);
106
+ return `${s.mtimeMs}:${s.size}:${s.mode}:${s.ino}`;
107
+ } catch (err) {
108
+ if (err.code === 'ENOENT' || err.code === 'ENOTDIR') return 'missing';
109
+ throw err;
110
+ }
111
+ }
112
+
113
+ /** Scan literal ownership prefixes, including directory entries so new files are noticed.
114
+ * @param {string} tree @param {string[]} owned @param {string[]} gitPaths
115
+ */
116
+ function waitTreeStamp(tree, owned, gitPaths) {
117
+ const entries = new Map();
118
+ /** @param {string} full */
119
+ function visit(full) {
120
+ if (entries.has(full)) return;
121
+ const stamp = fileStamp(full);
122
+ entries.set(full, stamp);
123
+ if (stamp !== 'missing' && lstatSync(full).isDirectory()) {
124
+ for (const name of readdirSync(full).sort()) {
125
+ if (name !== '.git' && name !== '.code-forge') visit(path.join(full, name));
126
+ }
127
+ }
128
+ }
129
+ for (const pattern of owned) {
130
+ const segments = pattern.split('/');
131
+ const glob = segments.findIndex((s) => /[*?{]/.test(s));
132
+ visit(path.join(tree, ...(glob < 0 ? segments : segments.slice(0, glob))));
133
+ }
134
+ for (const full of gitPaths) visit(full);
135
+ return JSON.stringify([...entries].sort(([a], [b]) => a.localeCompare(b)));
136
+ }
137
+
138
+ /** Git may store worktree metadata outside the content tree.
139
+ * @param {string} tree @returns {string[]}
140
+ */
141
+ function waitGitPaths(tree) {
142
+ let gitDir = path.join(tree, '.git');
143
+ if (lstatSync(gitDir).isFile()) gitDir = path.resolve(tree, readFileSync(gitDir, 'utf8').trim().replace(/^gitdir: /, ''));
144
+ let common = gitDir;
145
+ try { common = path.resolve(gitDir, readFileSync(path.join(gitDir, 'commondir'), 'utf8').trim()); }
146
+ catch (err) { if (err.code !== 'ENOENT') throw err; }
147
+ return [path.join(tree, '.gitignore'), ...new Set([gitDir, common])].flatMap((dir, i) => i === 0 ? [dir] : ['HEAD', 'index', 'packed-refs', 'refs', 'info/exclude'].map((f) => path.join(dir, f)));
148
+ }
94
149
 
95
150
  /**
96
151
  * `90s`, `90`, `1500ms` → milliseconds.
@@ -394,9 +449,11 @@ export function fixList(result) {
394
449
  * B100: read block scope from the workspace mirror, never the private run record. This is a
395
450
  * progress summary, not an approval: block close still verifies the authoritative signed rows.
396
451
  * @param {string} repoRoot @param {string} cwd @param {string} run @param {string} block
397
- * @returns {Promise<{entry: Record<string, any>, tree: string}>}
452
+ * @param {{scopeFile: string, stamp: string, entry: Record<string, any>, tree: string, cfg: Record<string, any> | null, project: string} | null} [cached]
453
+ * @returns {Promise<{scopeFile: string, stamp: string, entry: Record<string, any>, tree: string, cfg: Record<string, any> | null, project: string}>}
398
454
  */
399
- async function waitBlockScope(repoRoot, cwd, run, block) {
455
+ async function waitBlockScope(repoRoot, cwd, run, block, cached = null) {
456
+ if (cached && fileStamp(cached.scopeFile) === cached.stamp) return cached;
400
457
  const top = await repoRootOf(cwd);
401
458
  const candidates = new Set([repoRoot]);
402
459
  for (let dir = realpathSync(cwd); ; dir = path.dirname(dir)) {
@@ -416,7 +473,8 @@ async function waitBlockScope(repoRoot, cwd, run, block) {
416
473
  throw new WorkerError('block-scope-unreadable', 'the block scope cannot be read');
417
474
  }
418
475
  const tree = (await blockTree(record.workspace, entry))?.top ?? repoRoot;
419
- return { entry, tree };
476
+ const scopeFile = path.join(dir, '.code-forge', 'runs', `${run}.json`);
477
+ return { entry, tree, cfg: configInForce(record), project: record.project, scopeFile, stamp: fileStamp(scopeFile) };
420
478
  }
421
479
  throw new WorkerError('block-scope-unreadable', 'the block scope cannot be read');
422
480
  }
@@ -445,16 +503,32 @@ function blockTicketOutcome(marker) {
445
503
  return 'unavailable';
446
504
  }
447
505
 
506
+ /** Completion timings are progress metadata; old markers remain readable.
507
+ * @param {Record<string, any> | null} value @returns {Record<string, any>} */
508
+ function ticketTimings(value) {
509
+ return Object.fromEntries(['queued_at', 'started_at', 'finished_at', 'duration_ms']
510
+ .filter((key) => value && Object.hasOwn(value, key)).map((key) => [key, value[key]]));
511
+ }
512
+
448
513
  /**
449
514
  * A block wait uses unsigned results only to classify retries; it never reads the signing key.
450
- * Re-scan current content on every poll. Deliver unseen findings of the newest earlier ticket
515
+ * Re-scan metadata each poll and read content only after its stamp changes. Deliver unseen findings of the newest earlier ticket
451
516
  * before rechecking a blind edit; the next join queues the fix. Stopped files stay finished.
452
- * @param {{repoRoot: string, cwd: string, block: string, run: string, maxMs: number, pollMs: number, say: (obj: Record<string, any>) => unknown, dropDone?: typeof dropDoneIf}} opts
517
+ * @param {{repoRoot: string, cwd: string, block: string, run: string, maxMs: number, pollMs: number, say: (obj: Record<string, any>) => unknown, dropDone?: typeof dropDoneIf, now?: () => number, sleep?: (ms: number) => Promise<unknown>}} opts
453
518
  */
454
- async function waitBlock({ repoRoot, cwd, block, run, maxMs, pollMs, say, dropDone = dropDoneIf }) {
455
- const { entry, tree } = await waitBlockScope(repoRoot, cwd, run, block);
519
+ async function waitBlock({ repoRoot, cwd, block, run, maxMs, pollMs, say, dropDone = dropDoneIf, now = Date.now, sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)) }) {
520
+ let scope = await waitBlockScope(repoRoot, cwd, run, block);
521
+ const { tree } = scope;
522
+ const gitPaths = waitGitPaths(tree);
456
523
  const where = realpathSync(tree) === realpathSync(repoRoot) ? {} : { tree: realpathSync(tree) };
457
- const deadline = Date.now() + maxMs;
524
+ const deadline = now() + maxMs;
525
+ let interval = pollMs;
526
+ let seen = '';
527
+ let treeStamp = '';
528
+ /** @type {string[]} */
529
+ let current = [];
530
+ /** @type {Map<string, {stamp: string, content_hash: string}>} */
531
+ const hashes = new Map();
458
532
  let downPolls = 0;
459
533
  const deliveredHere = new Set();
460
534
  for (;;) {
@@ -462,25 +536,46 @@ async function waitBlock({ repoRoot, cwd, block, run, maxMs, pollMs, say, dropDo
462
536
  const live = worker?.run === run;
463
537
  downPolls = live ? 0 : downPolls + 1;
464
538
  const tickets = blockTickets({ repoRoot, run, block, ...where });
465
- const current = await blockFileSet({ repoRoot: tree, base: entry.base_sha, owned: entry.owned_files, ownedKinds: entry.owned_kinds });
466
- const files = current.map(({ file }) => {
467
- const opts = { repoRoot, run, block, file, ...where };
468
- const { ticket } = resolveTicket(opts);
469
- const previous = tickets.filter((t) => t.file === file);
539
+ scope = await waitBlockScope(repoRoot, cwd, run, block, scope);
540
+ const { entry, cfg, project } = scope;
541
+ const stamp = waitTreeStamp(tree, entry.owned_files, gitPaths) + scope.stamp;
542
+ if (stamp !== treeStamp) {
543
+ current = (await changedFiles({ repoRoot: tree, base: entry.base_sha })).filter((file) => ownsFile(entry.owned_files, file, entry.owned_kinds));
544
+ treeStamp = stamp;
545
+ }
546
+ // Progress only, like answersPrecheck: sandboxed callers may not read the private ledger.
547
+ // Reopens issued here also renew the workspace ticket, which needs no private read to join.
548
+ let rows = [];
549
+ try { rows = await readAllRows(project); } catch { /* The worker verifies the signed row. */ }
550
+ const reopens = new Map();
551
+ for (const row of rows) {
552
+ if (row.event !== 'review.reopened' || row.run !== run || row.block !== block || !Number.isFinite(Date.parse(row.ts))) continue;
553
+ if (row.by !== 'human' && !(row.by === 'agent' && row.verdict?.verdict === 'agree')) continue;
554
+ if (!reopens.has(row.file) || row.ts > reopens.get(row.file)) reopens.set(row.file, row.ts);
555
+ }
556
+ const files = current.map((file) => {
557
+ const rule = reviewExcludeRule(cfg, file);
558
+ if (rule !== null) return { file, ticket: null, status: 'excluded', outcome: null, rule };
559
+ const after = reopens.get(file);
560
+ const stamp = fileStamp(path.join(tree, file));
561
+ if (hashes.get(file)?.stamp !== stamp) hashes.set(file, { stamp, content_hash: contentHash(tree, file) });
562
+ const opts = { repoRoot, run, block, file, ...where, after, content_hash: hashes.get(file).content_hash };
563
+ const { ticket } = resolveHashTicket(opts);
564
+ const previous = tickets.filter((t) => t.file === file && (after === undefined || Date.parse(t.enqueued_at) > Date.parse(after)));
470
565
  let present = previous.find((t) => t.ticket === ticket);
471
566
  if (!present && previous.length > 0) {
472
567
  const newest = previous[previous.length - 1];
473
568
  const marker = blockTicketMarker(repoRoot, newest.ticket);
474
- if (marker?.status === 'stopped') return { file, ticket: newest.ticket, status: 'done', outcome: blockTicketOutcome(marker) };
569
+ if (marker?.status === 'stopped') return { file, ticket: newest.ticket, status: 'done', outcome: blockTicketOutcome(marker), ...ticketTimings(marker) };
475
570
  if (marker?.status === 'reviewed' && marker.open_findings === true && live) {
476
571
  if (deliveredHere.has(newest.ticket) || deliverBlockFindings({ repoRoot, block, file, ticket: newest.ticket })) {
477
572
  deliveredHere.add(newest.ticket);
478
- return { file, ticket: newest.ticket, status: 'findings', outcome: blockTicketOutcome(marker) };
573
+ return { file, ticket: newest.ticket, status: 'findings', outcome: blockTicketOutcome(marker), ...ticketTimings(marker) };
479
574
  }
480
575
  }
481
576
  }
482
577
  if (!present && live) {
483
- enqueue(opts);
578
+ enqueueHash(opts);
484
579
  present = { ...previous[previous.length - 1], ticket };
485
580
  }
486
581
  let marker = present ? blockTicketMarker(repoRoot, ticket) : null;
@@ -488,7 +583,7 @@ async function waitBlock({ repoRoot, cwd, block, run, maxMs, pollMs, say, dropDo
488
583
  dropFreshDone({ repoRoot, ticket, file, dropDone, once: true, statuses: RETRY_STATUSES });
489
584
  marker = blockTicketMarker(repoRoot, ticket);
490
585
  }
491
- return { file, ticket: present ? ticket : null, status: !present ? 'not_queued' : marker ? 'done' : 'pending', outcome: blockTicketOutcome(marker) };
586
+ return { file, ticket: present ? ticket : null, status: !present ? 'not_queued' : marker ? 'done' : 'pending', outcome: blockTicketOutcome(marker), ...ticketTimings(marker) };
492
587
  });
493
588
  if (downPolls >= 2) {
494
589
  say({ block, files, done: false, status: 'worker_down' });
@@ -499,38 +594,65 @@ async function waitBlock({ repoRoot, cwd, block, run, maxMs, pollMs, say, dropDo
499
594
  say({ block, files, done: false, reason: 'split_required', next: `The owner raises review.budgets for ${split.file}, runs run reload, then review-file ${split.file} --fresh.` });
500
595
  return 0;
501
596
  }
502
- if (live && files.every((f) => f.status === 'done' || f.status === 'findings')) {
597
+ if (live && files.every((f) => f.status === 'done' || f.status === 'findings' || f.status === 'excluded')) {
503
598
  say({ block, files, done: true });
504
599
  return 0;
505
600
  }
506
- if (live && (files.some((f) => f.status === 'not_queued') || Date.now() >= deadline)) {
601
+ if (live && (files.some((f) => f.status === 'not_queued') || now() >= deadline)) {
507
602
  say({ block, files, done: false, reason: files.some((f) => f.status === 'not_queued') ? 'not_queued' : 'wait_timeout' });
508
603
  return 0;
509
604
  }
510
- await new Promise((resolve) => setTimeout(resolve, pollMs));
605
+ const ticketIds = [...new Set([...tickets.map((t) => t.ticket), ...files.map((f) => f.ticket).filter(Boolean)])].sort();
606
+ const state = JSON.stringify([stamp, live, ticketIds, files]);
607
+ interval = seen === state ? Math.min(MAX_IDLE_MS, interval * 2) : pollMs;
608
+ seen = state;
609
+ await sleep(Math.min(interval, Math.max(0, deadline - now())));
511
610
  }
512
611
  }
513
612
 
514
613
  /**
515
614
  * @param {string[]} args
516
- * @param {{stdout?: {write: (s: string) => unknown}, stderr?: {write: (s: string) => unknown}, cwd?: string, pollMs?: number, dropDone?: typeof dropDoneIf}} [deps] - `dropDone`: B75's claim, for tests.
615
+ * @param {{stdout?: {write: (s: string) => unknown}, stderr?: {write: (s: string) => unknown}, cwd?: string, pollMs?: number, dropDone?: typeof dropDoneIf, now?: () => number, sleep?: (ms: number) => Promise<unknown>}} [deps] - `dropDone`: B75's claim, for tests.
517
616
  * @returns {Promise<number>}
518
617
  */
519
618
  export async function runReviewFile(args, deps = {}) {
520
619
  const stdout = deps.stdout ?? process.stdout;
521
620
  const stderr = deps.stderr ?? process.stderr;
522
621
  const say = (/** @type {Record<string, any>} */ obj) => writeSafe(stdout, `${JSON.stringify(obj)}\n`);
523
- const cwd = deps.cwd ?? process.cwd();
524
622
  try {
525
- const { flags, positionals } = parseFlags(args, { values: ['block', 'run', 'wait', 'wait-block', 'max', 'answers', 'of'], booleans: ['fresh'] });
623
+ const { flags, positionals } = parseFlags(args, { values: ['cwd', 'block', 'run', 'wait', 'wait-block', 'max', 'answers', 'of'], booleans: ['fresh', 'rebase-check'] });
624
+ let cwd = deps.cwd ?? process.cwd();
625
+ if (typeof flags.cwd === 'string') {
626
+ cwd = path.resolve(cwd, flags.cwd);
627
+ try {
628
+ if (!statSync(cwd).isDirectory()) throw new Error('not a directory');
629
+ } catch {
630
+ throw new WorkerError('usage', '--cwd must name an existing directory');
631
+ }
632
+ }
526
633
  // the queue and the worker: the main tree, also from inside a block's worktree
527
634
  const repoRoot = await mainRootOf(cwd);
528
635
 
636
+ if (flags['rebase-check'] === true) {
637
+ if (positionals.length || typeof flags.block !== 'string' || typeof flags.run !== 'string' || ['wait', 'wait-block', 'max', 'answers', 'of', 'fresh'].some((key) => flags[key] !== undefined)) {
638
+ throw new WorkerError('usage', '--rebase-check takes --block and --run only');
639
+ }
640
+ const block = assertBlockId(flags.block);
641
+ const runId = assertRunId(flags.run);
642
+ const worker = liveWorker(repoRoot);
643
+ if (!worker || worker.run !== runId) { say({ status: 'worker_down', block }); return 3; }
644
+ const record = await readRun(runId);
645
+ if (await mainRootOf(record.workspace) !== repoRoot) throw new WorkerError('other-workspace', 'the run belongs to another workspace');
646
+ const result = await rebaseBlock({ runId, id: block, rebaseCheck: true, writeRow: (row) => appendRow(row, { slug: record.project }) });
647
+ say({ block, status: 'queued', ...result, next: `code-forge block rebase ${block} --run ${runId}` });
648
+ return 0;
649
+ }
650
+
529
651
  if (flags['wait-block'] !== undefined) {
530
652
  if (positionals.length > 0 || flags.block !== undefined || flags.wait !== undefined || flags.answers !== undefined || flags.of !== undefined || flags.fresh === true || typeof flags.run !== 'string') {
531
653
  throw new WorkerError('usage', '--wait-block takes a block, --run and --max only');
532
654
  }
533
- return await waitBlock({ repoRoot, cwd, block: assertBlockId(flags['wait-block']), run: assertRunId(flags.run), maxMs: flags.max === undefined ? DEFAULT_WAIT_MS : parseMax(flags.max), pollMs: deps.pollMs ?? POLL_MS, say, dropDone: deps.dropDone });
655
+ return await waitBlock({ repoRoot, cwd, block: assertBlockId(flags['wait-block']), run: assertRunId(flags.run), maxMs: flags.max === undefined ? DEFAULT_WAIT_MS : parseMax(flags.max), pollMs: deps.pollMs ?? POLL_MS, say, dropDone: deps.dropDone, now: deps.now, sleep: deps.sleep });
534
656
  }
535
657
 
536
658
  if (typeof flags.wait === 'string') {
@@ -542,7 +664,7 @@ export async function runReviewFile(args, deps = {}) {
542
664
  for (;;) {
543
665
  if (isDone(repoRoot, ticket)) {
544
666
  const result = doneResult(repoRoot, ticket);
545
- say({ ticket, status: 'done', result, ...fixList(result) });
667
+ say({ ticket, status: 'done', result, ...ticketTimings(result), ...fixList(result) });
546
668
  return 0;
547
669
  }
548
670
  downPolls = liveWorker(repoRoot) ? 0 : downPolls + 1;
@@ -561,9 +683,23 @@ export async function runReviewFile(args, deps = {}) {
561
683
  if (positionals.length !== 1 || typeof flags.block !== 'string' || flags.max !== undefined) throw new WorkerError('usage', 'give one path and --block');
562
684
  const block = assertBlockId(flags.block);
563
685
  if (flags.fresh === true && (flags.answers !== undefined || flags.of !== undefined)) throw new WorkerError('usage', '--fresh takes no --answers or --of');
564
- if (flags.answers !== undefined || flags.of !== undefined) return await enqueueAnswersTicket({ flags, answering: positionals[0], block, cwd, repoRoot, say });
565
686
  const file = normalizeRequestPath(positionals[0], cwd, await repoRootOf(cwd));
566
687
  const worker = liveWorker(repoRoot);
688
+ let cfg = null;
689
+ const requestedRun = typeof flags.run === 'string' ? flags.run : worker?.run;
690
+ if (requestedRun) {
691
+ try { cfg = configInForce(await readRun(requestedRun)); }
692
+ catch { /* The workspace mirror below also serves sandboxed callers. */ }
693
+ }
694
+ const candidates = [file, ...(typeof flags.of === 'string' ? [normalizeRequestPath(flags.of, cwd, await repoRootOf(cwd))] : [])];
695
+ for (const candidate of candidates) {
696
+ const rule = requestedRun ? reviewExclusionFor(repoRoot, requestedRun, candidate, cfg) : reviewExcludeRule(cfg, candidate);
697
+ if (rule !== null) {
698
+ writeSafe(stdout, `not reviewed (review.exclude ${rule}): ${candidate}\n`);
699
+ return 0;
700
+ }
701
+ }
702
+ if (flags.answers !== undefined || flags.of !== undefined) return await enqueueAnswersTicket({ flags, answering: positionals[0], block, cwd, repoRoot, say });
567
703
  if (!worker || (typeof flags.run === 'string' && flags.run !== worker.run)) {
568
704
  say({ status: 'worker_down', file });
569
705
  return 3;
@@ -574,12 +710,6 @@ export async function runReviewFile(args, deps = {}) {
574
710
  const tree = recorded === null ? await repoRootOf(cwd) : (recorded.top ?? repoRoot);
575
711
  assertInsideRoot(tree, file); // the path, re-checked in the tree its content is read from
576
712
  const where = realpathSync(tree) === realpathSync(repoRoot) ? {} : { tree };
577
- let cfg = null;
578
- try {
579
- cfg = configInForce(await readRun(worker.run));
580
- } catch {
581
- // No readable snapshot: retain the existing cache and --fresh rules.
582
- }
583
713
  if (flags.fresh === true) return freshTicket({ repoRoot, run: worker.run, block, file, where, say, cfg, stderr, ...(deps.dropDone ? { dropDone: deps.dropDone } : {}) });
584
714
  const queued = enqueue({ repoRoot, run: worker.run, block, file, ...where });
585
715
  const cached = queued.status === 'done' ? doneResult(repoRoot, queued.ticket) : null;
package/src/cli/run.mjs CHANGED
@@ -40,7 +40,8 @@ import { configInForce } from '../state/config-snapshot.mjs';
40
40
  import { endRun, processStartTime, readRun, reattachWorker, RUN_MODES, startRun, stopPinnedWorker } from '../state/run.mjs';
41
41
  import { writeSafe } from '../util/redact.mjs';
42
42
  import { withLock } from '../util/file-locks.mjs';
43
- import { sweepRoots, tmpBase } from '../util/tmp.mjs';
43
+ import { sweepProcesses } from '../util/reaper.mjs';
44
+ import { tmpBase } from '../util/tmp.mjs';
44
45
  import { launchWorker, repinWorker } from '../worker/loop.mjs';
45
46
  import { liveWorker, workerRegistry } from '../worker/queue.mjs';
46
47
  import { repoRootOf } from '../worker/ticket.mjs';
@@ -173,7 +174,7 @@ export async function runRun(args, deps = {}) {
173
174
  const project = slugFor(loaded.config, workspace);
174
175
  const engine = typeof flags.engine === 'string' ? flags.engine : undefined;
175
176
  const tmpRoot = typeof loaded.config?.tmp?.root === 'string' ? loaded.config.tmp.root : undefined;
176
- await sweepRoots({ root: tmpBase(tmpRoot) }); // dead runs' temp roots and orphans (§9.6, V12)
177
+ await sweepProcesses({ apply: true, root: tmpBase(tmpRoot), zombies: false });
177
178
  let record = await startRun({ workspace, project, config: loaded.config, engine, mode, runId, workerPid, writeRow: ledgerWriter(project), probe });
178
179
  if (!record.worker && startWorker) {
179
180
  const launched = await startWorker({ runId: record.run_id, workspace });
@@ -0,0 +1,28 @@
1
+ import { parseFlags } from '../state/cli-args.mjs';
2
+ import { sweepProcesses } from '../util/reaper.mjs';
3
+ import { writeSafe } from '../util/redact.mjs';
4
+
5
+ const HELP = 'usage: code-forge sweep [--apply] [--json]\nDry run by default: list stale workers, dead-run registry children and marked orphans with RSS and total MB.\n--apply stops proven stale processes with SIGTERM, then SIGKILL after a grace period.\nZombies are reported by parent only; neither they nor their parents are signalled. Live engines have no memory limit.\n';
6
+
7
+ /** @param {string[]} args @param {{sweep?:typeof sweepProcesses}} [deps] */
8
+ export default async function runSweep(args, deps = {}) {
9
+ let flags;
10
+ try {
11
+ const parsed = parseFlags(args, { booleans: ['apply', 'json', 'help'] });
12
+ if (parsed.positionals.length) throw new Error('unexpected positional argument');
13
+ flags = parsed.flags;
14
+ } catch {
15
+ writeSafe(process.stderr, HELP);
16
+ return 2;
17
+ }
18
+ if (flags.help) { writeSafe(process.stdout, HELP); return 0; }
19
+ const report = await (deps.sweep ?? sweepProcesses)({ apply: flags.apply === true });
20
+ if (flags.json) writeSafe(process.stdout, `${JSON.stringify(report)}\n`);
21
+ else {
22
+ for (const item of report.items) writeSafe(process.stdout,
23
+ `${report.apply ? item.action ?? 'stopped' : 'would stop'} ${item.kind} pid ${item.pid} ${item.rssMb.toFixed(1)} MB ${item.command}\n`);
24
+ for (const group of report.zombies) writeSafe(process.stdout, `${group.count} zombies (parent ${group.ppid} ${group.command}) — report only\n`);
25
+ writeSafe(process.stdout, `${report.apply ? 'sweep' : 'dry run'}: ${report.items.length} processes, total ${report.totalRssMb.toFixed(1)} MB; ${report.registryRoots.length} dead-run roots\n`);
26
+ }
27
+ return 0;
28
+ }
@@ -11,6 +11,7 @@ import { loadConfigFile, DEFAULT_CONFIG_FILENAME } from '../config/load.mjs';
11
11
  import { loadSeenInCache } from '../config/refresh.mjs';
12
12
  import { maskSecretTokens } from '../config/secret-patterns.mjs';
13
13
  import { validateConfig } from '../config/validate.mjs';
14
+ import { loadPipeline } from '../pipeline/pipeline.mjs';
14
15
  import * as log from '../util/log.mjs';
15
16
 
16
17
  /** Provider -> the CLI binary name `engine: subprocess` would spawn for it. */
@@ -100,6 +101,11 @@ export default async function validate(args) {
100
101
  }
101
102
 
102
103
  if (result.valid) {
104
+ const pipeline = loadPipeline(path.dirname(parsed.file));
105
+ if (pipeline.exists && !pipeline.ok) {
106
+ for (const problem of pipeline.problems) log.error(maskSecretTokens(problem));
107
+ return 1;
108
+ }
103
109
  log.info(`${parsed.file}: valid (${result.warnings.length} warning${result.warnings.length === 1 ? '' : 's'})`);
104
110
  return 0;
105
111
  }
@@ -4,7 +4,7 @@
4
4
  *
5
5
  * OpenAI's structured-output "strict" mode has three rules a hand-written schema violates by
6
6
  * default: (1) every property under an object must be listed in `required` (optional fields are
7
- * instead modeled as **nullable** required fields), (2) every object must set
7
+ * instead modeled as **nullable** required fields; source-required keys stay as they are), (2) every object must set
8
8
  * `additionalProperties: false`, (3) nothing may be missing from `required`. Claude and Grok take
9
9
  * the source schema unchanged (they support ordinary JSON Schema `required`/optional).
10
10
  *
@@ -149,8 +149,9 @@ function childSchemas(node) {
149
149
  }
150
150
 
151
151
  /**
152
- * Recursively rewrites every object node: a `properties` map makes every property `required` and
153
- * nullable and closes the node (unless it already has an open-ended `additionalProperties`); a
152
+ * Recursively rewrites every object node: a `properties` map makes every property `required`;
153
+ * optional keys become nullable; required keys stay as they are. It closes the node (unless it
154
+ * already has an open-ended `additionalProperties`); a
154
155
  * bare `{type: 'object'}` is closed to an empty strict object. `visited` guards against revisiting
155
156
  * the same shared node twice — not against a genuine schema cycle, which this project's schema
156
157
  * does not have.
@@ -165,12 +166,13 @@ function compileForOpenAI(node, visited) {
165
166
 
166
167
  if (node.properties && typeof node.properties === 'object') {
167
168
  const keys = Object.keys(node.properties);
169
+ const wasRequired = new Set(Array.isArray(node.required) ? node.required : []);
168
170
  node.required = keys;
169
171
  if (node.additionalProperties === undefined) {
170
172
  node.additionalProperties = false;
171
173
  }
172
174
  for (const key of keys) {
173
- makeNullable(node.properties[key]);
175
+ if (!wasRequired.has(key)) makeNullable(node.properties[key]);
174
176
  }
175
177
  } else if (isObjectTyped(node) && node.additionalProperties === undefined && node.patternProperties === undefined) {
176
178
  node.properties = {};
@@ -66,7 +66,9 @@
66
66
  import { Ajv2020 } from 'ajv/dist/2020.js';
67
67
  import schema from '../../schema/code-forge.schema.json' with { type: 'json' };
68
68
  import { redact } from '../util/redact.mjs';
69
+ import { packageVersion } from '../util/error-log.mjs';
69
70
  import { TEST_FILES_TOKEN, CHANGED_FILES_TOKEN } from '../util/gate-token.mjs';
71
+ import { BLOCK_KINDS } from '../decide/escalation.mjs';
70
72
  import { checkEffort, displayEffort } from '../engines/efforts.mjs';
71
73
  import { CLOSED_BOOK_LEVELS, CODEX_CLOSED_BOOK_REFUSAL, isNoClosedBookProvider, OPEN_BOOK_CODEX_WARNING, openBookCodexAllowed } from './closed-book.mjs';
72
74
  import { isKnownId, resolveLevel } from './known-ids.mjs';
@@ -75,6 +77,7 @@ import { isKnownId, resolveLevel } from './known-ids.mjs';
75
77
  import { ROUTE_LEVELS, ruleProvider } from '../review/route.mjs';
76
78
  import { DEFAULT_SANDBOX_PROFILE, SANDBOX_PROFILES, sandboxDirRefusals, SANDBOX_DIR_REFUSED } from '../engines/sandbox-dirs.mjs';
77
79
  import { tmpBase } from '../util/tmp.mjs';
80
+ import { expandBraces, globMatch } from '../state/registry.mjs';
78
81
  import { looksLikeSecret, maskSecretTokens, SECRET_MASK, secretTokensIn } from './secret-patterns.mjs';
79
82
 
80
83
  /**
@@ -211,7 +214,7 @@ function runSchemaValidation(cfg) {
211
214
  if (err.keyword === 'propertyNames' && typeof err.params?.propertyName === 'string') {
212
215
  detail = `property name "${safeSegment(err.params.propertyName)}" is not allowed (propertyNames)`;
213
216
  } else if (err.keyword === 'additionalProperties' && typeof err.params?.additionalProperty === 'string') {
214
- detail = `must NOT have additional property "${safeSegment(err.params.additionalProperty)}"`;
217
+ detail = `must NOT have additional property "${safeSegment(err.params.additionalProperty)}" (unknown key; if it comes from a newer code-forge, upgrade: this is code-forge ${packageVersion()})`;
215
218
  }
216
219
  return {
217
220
  rule: 'schema',
@@ -512,12 +515,76 @@ function gateMessages(key, value) {
512
515
  return messages;
513
516
  }
514
517
 
518
+ /** Custom gate refusals are also checked before running commands or reading evidence.
519
+ * @param {unknown} custom @returns {string[]}
520
+ */
521
+ export function customGateMessages(custom) {
522
+ if (custom === undefined) return [];
523
+ if (!Array.isArray(custom)) return ['gates.custom must be an array of at most 20 named object gates'];
524
+ const messages = custom.length > 20 ? ['gates.custom must contain at most 20 entries'] : [];
525
+ const names = new Set();
526
+ for (const [i, value] of custom.entries()) {
527
+ const key = `custom[${i}]`;
528
+ if (!value || typeof value !== 'object' || Array.isArray(value)) {
529
+ messages.push(`gates.${key} must be a named object gate`);
530
+ continue;
531
+ }
532
+ const { name, evidence, ...command } = value;
533
+ const label = `${key}${typeof name === 'string' ? ` (${safeSegment(name)})` : ''}`;
534
+ if (typeof name !== 'string' || !/^[a-z][a-z0-9_-]{0,31}$/.test(name)) messages.push(`gates.${label}.name must match ^[a-z][a-z0-9_-]{0,31}$`);
535
+ if (GATE_KEYS.includes(name)) messages.push(`gates.${label}.name must not be a fixed gate name`);
536
+ if (names.has(name)) messages.push(`gates.${label}.name must be unique`);
537
+ names.add(name);
538
+ messages.push(...gateMessages(label, command));
539
+ if (Array.isArray(command.argv) && command.argv.some((arg) => typeof arg === 'string' && [TEST_FILES_TOKEN, CHANGED_FILES_TOKEN].some((token) => arg.includes(token)))) {
540
+ messages.push(`gates.${label}.argv: {test_files} and {changed_files} are refused in custom gates`);
541
+ }
542
+ if (evidence !== undefined) {
543
+ if (!Array.isArray(evidence)) messages.push(`gates.${label}.evidence must be an array of safe relative paths or simple globs`);
544
+ else for (const [j, pattern] of evidence.entries()) {
545
+ // Backslashes are refused as well so a Windows absolute/traversal path is never
546
+ // interpreted differently by validation and the POSIX evidence matcher.
547
+ if (typeof pattern !== 'string' || pattern.length === 0 || pattern.startsWith('/') || /[\\\0]/.test(pattern) || pattern.split('/').some((segment) => segment === '..' || /^[A-Za-z]:/.test(segment))) {
548
+ messages.push(`gates.${label}.evidence[${j}] must be a non-empty relative path or glob without absolute paths or a .. segment`);
549
+ }
550
+ }
551
+ }
552
+ }
553
+ return messages;
554
+ }
555
+
556
+ /** Gate names in a kind set must resolve to an available configured or detected gate.
557
+ * @param {Record<string, any> | undefined} gates @returns {string[]}
558
+ */
559
+ export function kindGateMessages(gates) {
560
+ const custom = new Set(Array.isArray(gates?.custom) ? gates.custom.map((gate) => gate?.name) : []);
561
+ const messages = [];
562
+ for (const kind of BLOCK_KINDS) {
563
+ const names = gates?.kinds?.[kind];
564
+ if (!Array.isArray(names)) continue; // The schema handles invalid shapes.
565
+ for (const name of names) {
566
+ if (typeof name !== 'string') continue;
567
+ const label = `gates.kinds.${kind}: ${safeSegment(name)}`;
568
+ const fixed = GATE_KEYS.some((key) => key === name);
569
+ if (!fixed && !custom.has(name)) messages.push(`${label} is an unknown gate name`);
570
+ else if (fixed && gates[name] === null) messages.push(`${label} is a null fixed gate`);
571
+ }
572
+ }
573
+ return messages;
574
+ }
575
+
515
576
  function checkGatesNotArgvArray(cfg) {
516
577
  const messages = GATE_KEYS.flatMap((key) => gateMessages(key, cfg?.gates?.[key]));
578
+ messages.push(...customGateMessages(cfg?.gates?.custom));
579
+ messages.push(...kindGateMessages(cfg?.gates));
517
580
  if (Object.hasOwn(cfg?.gates ?? {}, 'repeat_new_tests')) {
518
581
  const n = cfg.gates.repeat_new_tests;
519
582
  if (!Number.isInteger(n) || n < 2 || n > 10) messages.push('gates.repeat_new_tests must be an integer 2..10');
520
583
  }
584
+ if (Object.hasOwn(cfg?.gates ?? {}, 'timeout_s')) {
585
+ const n = cfg.gates.timeout_s;
586
+ if (!Number.isInteger(n) || n < 30 || n > 7200) messages.push('gates.timeout_s must be an integer 30..7200');
587
+ }
521
588
  return messages.map((message) => ({ rule: 'gates-not-argv-array', severity: 'error', message }));
522
589
  }
523
590
 
@@ -1181,3 +1248,18 @@ export function reviewFileLoop(cfg) {
1181
1248
  export function reviewWorkers(cfg) {
1182
1249
  return cfg?.review?.workers ?? 1;
1183
1250
  }
1251
+
1252
+ /** Common generated lock files, matched at the root and at any depth. */
1253
+ export const DEFAULT_REVIEW_EXCLUDE = Object.freeze([
1254
+ 'package-lock.json', 'npm-shrinkwrap.json', 'yarn.lock', 'pnpm-lock.yaml', 'bun.lock', 'bun.lockb',
1255
+ 'composer.lock', 'Cargo.lock', 'Gemfile.lock', 'poetry.lock', 'uv.lock', 'go.sum',
1256
+ 'Package.resolved', 'Podfile.lock', 'pubspec.lock', 'mix.lock', 'flake.lock',
1257
+ ].map((name) => `**/${name}`));
1258
+
1259
+ /** The first matching repo-relative rule, or null. A supplied list replaces the defaults.
1260
+ * @param {Record<string, any> | null} cfg @param {string} file @returns {string | null}
1261
+ */
1262
+ export function reviewExcludeRule(cfg, file) {
1263
+ const rules = cfg?.review?.exclude ?? DEFAULT_REVIEW_EXCLUDE;
1264
+ return rules.find((rule) => expandBraces(rule).some((pattern) => globMatch(file, pattern))) ?? null;
1265
+ }