@codedology/code-forge 0.15.2 → 0.15.4

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 (79) hide show
  1. package/CHANGELOG.md +81 -0
  2. package/README.md +5 -1
  3. package/docs/README.md +2 -0
  4. package/docs/reference/blocks.json +162 -1
  5. package/docs/reference/config.md +38 -15
  6. package/native/README.md +10 -0
  7. package/native/bin/darwin-arm64/code-forge-native +0 -0
  8. package/native/bin/darwin-x64/code-forge-native +0 -0
  9. package/native/bin/linux-arm64/code-forge-native +0 -0
  10. package/native/bin/linux-x64/code-forge-native +0 -0
  11. package/package.json +4 -1
  12. package/schema/code-forge.schema.json +83 -3
  13. package/schema/outcome.schema.json +45 -0
  14. package/schema/pipeline.schema.json +30 -0
  15. package/scripts/first-use.mjs +8 -0
  16. package/scripts/gen-config-doc.mjs +1 -1
  17. package/skill/references/autopilot.md +6 -1
  18. package/skill/references/code.md +52 -0
  19. package/skill/references/ledger.md +13 -1
  20. package/skill/references/plan.md +5 -1
  21. package/skill/references/review.md +46 -8
  22. package/skill/scripts/forge +1 -1
  23. package/src/cli/block.mjs +427 -38
  24. package/src/cli/context.mjs +17 -7
  25. package/src/cli/doctor.mjs +32 -1
  26. package/src/cli/gates.mjs +76 -10
  27. package/src/cli/help.mjs +5 -0
  28. package/src/cli/native-status.mjs +13 -0
  29. package/src/cli/outcome.mjs +87 -0
  30. package/src/cli/pipeline.mjs +61 -0
  31. package/src/cli/proof.mjs +10 -7
  32. package/src/cli/report.mjs +53 -6
  33. package/src/cli/review-file.mjs +278 -41
  34. package/src/cli/run.mjs +3 -2
  35. package/src/cli/status.mjs +43 -9
  36. package/src/cli/sweep.mjs +28 -0
  37. package/src/cli/validate.mjs +6 -0
  38. package/src/config/validate.mjs +83 -1
  39. package/src/context/pack.mjs +53 -3
  40. package/src/doctor/index.mjs +11 -0
  41. package/src/doctor/local.mjs +22 -4
  42. package/src/doctor/worker-probe.mjs +1 -1
  43. package/src/engines/builders/codex.mjs +27 -7
  44. package/src/engines/builders/grok.mjs +11 -5
  45. package/src/engines/codex-home.mjs +18 -15
  46. package/src/engines/sentinel.mjs +11 -0
  47. package/src/gates/detect.mjs +6 -3
  48. package/src/gates/run.mjs +295 -27
  49. package/src/ledger/export.mjs +1 -1
  50. package/src/ledger/report.mjs +143 -5
  51. package/src/outcome/outcome.mjs +108 -0
  52. package/src/pipeline/pipeline.mjs +112 -0
  53. package/src/proof/red-green.mjs +41 -13
  54. package/src/proof/runners.mjs +28 -4
  55. package/src/proof/worktree.mjs +25 -15
  56. package/src/review/agent-waiver.mjs +113 -12
  57. package/src/review/engine.mjs +25 -5
  58. package/src/review/gate-check.mjs +52 -10
  59. package/src/review/lenses/full.md +1 -0
  60. package/src/review/lenses/quick.md +1 -0
  61. package/src/review/model-stats.mjs +125 -0
  62. package/src/review/packet.mjs +88 -9
  63. package/src/review/related.mjs +112 -0
  64. package/src/review/reopen.mjs +42 -16
  65. package/src/review/route.mjs +58 -4
  66. package/src/review/session-retry.mjs +59 -0
  67. package/src/session/facts.mjs +19 -7
  68. package/src/session/plan-check.mjs +4 -1
  69. package/src/session/spawn.mjs +54 -3
  70. package/src/state/block.mjs +139 -49
  71. package/src/state/config-snapshot.mjs +13 -3
  72. package/src/state/run.mjs +3 -2
  73. package/src/util/exec.mjs +48 -31
  74. package/src/util/native.mjs +131 -0
  75. package/src/util/reaper.mjs +238 -8
  76. package/src/worker/engine.mjs +7 -3
  77. package/src/worker/loop.mjs +211 -30
  78. package/src/worker/queue.mjs +107 -24
  79. package/src/worker/watch.mjs +18 -7
@@ -67,30 +67,91 @@
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, watch } 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 { setTimeout as delay } from 'node:timers/promises';
73
+ import { answersTicketId, assertAnswers, blockTickets, clearDone, deliverBlockFindings, dropDoneIf, enqueue, enqueueAnswers, enqueueHash, isDone, liveWorker, MAX_ANSWERS, queueDir, resolveHashTicket, resolveTicket, reviewExclusionFor } from '../worker/queue.mjs';
73
74
  import { assertBlockId, assertInsideRoot, assertTicketId, contentHash, mainRootOf, normalizeRequestPath, repoRootOf, WorkerError } from '../worker/ticket.mjs';
74
- import { blockFileSet, namedOnHash } from '../review/gate-check.mjs';
75
+ import { changedFiles, checkBlockReviews, namedOnHash, ownsAt } from '../review/gate-check.mjs';
76
+ import { AUTO_REQUEUE_REASON, isAutomaticReopen, reopenFile, stopContentHash } from '../review/reopen.mjs';
77
+ import { loadState } from '../worker/review-state.mjs';
75
78
  import { STOP_EXITS } from '../review/stop-exits.mjs';
76
79
  import { budgetFor } from '../review/packet.mjs';
77
80
  import { ownsFile } from '../state/registry.mjs';
78
- import { readAllRows } from '../ledger/write.mjs';
81
+ import { appendRow, readAllRows } from '../ledger/write.mjs';
79
82
  import { parseFlags } from '../state/cli-args.mjs';
80
83
  import { StateError } from '../state/paths.mjs';
81
84
  import { assertRunId } from '../state/paths.mjs';
82
- import { readRun } from '../state/run.mjs';
85
+ import { readRun, writeSigned } from '../state/run.mjs';
86
+ import { loadKey } from '../state/signer.mjs';
87
+ import { rebaseBlock } from '../state/block.mjs';
83
88
  import { configInForce } from '../state/config-snapshot.mjs';
89
+ import { reviewExcludeRule } from '../config/validate.mjs';
84
90
  import { blockTree } from '../proof/worktree.mjs';
85
91
  import { writeSafe } from '../util/redact.mjs';
92
+ import { daemonRequest } from '../util/native.mjs';
93
+ import { runRootPath } from '../util/tmp.mjs';
86
94
 
87
95
  const USAGE =
88
- 'usage: code-forge review-file <path> --block <id> [--run <id>] [--fresh] | review-file --wait <ticket> [--max <seconds>s]\n' +
96
+ 'usage: code-forge review-file [--cwd <dir>] <path> --block <id> [--run <id>] [--fresh] | review-file --wait <ticket> [--max <seconds>s]\n' +
89
97
  ' 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';
98
+ ' code-forge review-file --rebase-check --block <id> --run <id>\n' +
99
+ ' code-forge review-file <answering file> --block <id> --answers <id,id,…> --of <source> [--run <id>]\n' +
100
+ ' --cwd <dir> applies to queueing and both wait modes\n';
91
101
 
92
102
  export const DEFAULT_WAIT_MS = 90_000;
93
103
  const POLL_MS = 200;
104
+ const MAX_IDLE_MS = 5000;
105
+
106
+ /** Metadata only: never open file content during an idle poll.
107
+ * @param {string} file @returns {string}
108
+ */
109
+ function fileStamp(file) {
110
+ try {
111
+ const s = lstatSync(file);
112
+ return `${s.mtimeMs}:${s.size}:${s.mode}:${s.ino}`;
113
+ } catch (err) {
114
+ if (err.code === 'ENOENT' || err.code === 'ENOTDIR') return 'missing';
115
+ throw err;
116
+ }
117
+ }
118
+
119
+ /** Scan literal ownership prefixes, including directory entries so new files are noticed.
120
+ * @param {string} tree @param {string[]} owned @param {string[]} gitPaths
121
+ */
122
+ function waitTreeStamp(tree, owned, gitPaths) {
123
+ const entries = new Map();
124
+ /** @param {string} full */
125
+ function visit(full) {
126
+ if (entries.has(full)) return;
127
+ const stamp = fileStamp(full);
128
+ entries.set(full, stamp);
129
+ if (stamp !== 'missing' && lstatSync(full).isDirectory()) {
130
+ for (const name of readdirSync(full).sort()) {
131
+ if (name !== '.git' && name !== '.code-forge') visit(path.join(full, name));
132
+ }
133
+ }
134
+ }
135
+ for (const pattern of owned) {
136
+ const segments = pattern.split('/');
137
+ const glob = segments.findIndex((s) => /[*?{]/.test(s));
138
+ visit(path.join(tree, ...(glob < 0 ? segments : segments.slice(0, glob))));
139
+ }
140
+ for (const full of gitPaths) visit(full);
141
+ return JSON.stringify([...entries].sort(([a], [b]) => a.localeCompare(b)));
142
+ }
143
+
144
+ /** Git may store worktree metadata outside the content tree.
145
+ * @param {string} tree @returns {string[]}
146
+ */
147
+ function waitGitPaths(tree) {
148
+ let gitDir = path.join(tree, '.git');
149
+ if (lstatSync(gitDir).isFile()) gitDir = path.resolve(tree, readFileSync(gitDir, 'utf8').trim().replace(/^gitdir: /, ''));
150
+ let common = gitDir;
151
+ try { common = path.resolve(gitDir, readFileSync(path.join(gitDir, 'commondir'), 'utf8').trim()); }
152
+ catch (err) { if (err.code !== 'ENOENT') throw err; }
153
+ 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)));
154
+ }
94
155
 
95
156
  /**
96
157
  * `90s`, `90`, `1500ms` → milliseconds.
@@ -394,9 +455,11 @@ export function fixList(result) {
394
455
  * B100: read block scope from the workspace mirror, never the private run record. This is a
395
456
  * progress summary, not an approval: block close still verifies the authoritative signed rows.
396
457
  * @param {string} repoRoot @param {string} cwd @param {string} run @param {string} block
397
- * @returns {Promise<{entry: Record<string, any>, tree: string}>}
458
+ * @param {{scopeFile: string, stamp: string, entry: Record<string, any>, tree: string, cfg: Record<string, any> | null, project: string} | null} [cached]
459
+ * @returns {Promise<{scopeFile: string, stamp: string, entry: Record<string, any>, tree: string, cfg: Record<string, any> | null, project: string}>}
398
460
  */
399
- async function waitBlockScope(repoRoot, cwd, run, block) {
461
+ async function waitBlockScope(repoRoot, cwd, run, block, cached = null) {
462
+ if (cached && fileStamp(cached.scopeFile) === cached.stamp) return cached;
400
463
  const top = await repoRootOf(cwd);
401
464
  const candidates = new Set([repoRoot]);
402
465
  for (let dir = realpathSync(cwd); ; dir = path.dirname(dir)) {
@@ -416,7 +479,8 @@ async function waitBlockScope(repoRoot, cwd, run, block) {
416
479
  throw new WorkerError('block-scope-unreadable', 'the block scope cannot be read');
417
480
  }
418
481
  const tree = (await blockTree(record.workspace, entry))?.top ?? repoRoot;
419
- return { entry, tree };
482
+ const scopeFile = path.join(dir, '.code-forge', 'runs', `${run}.json`);
483
+ return { entry, tree, cfg: configInForce(record), project: record.project, scopeFile, stamp: fileStamp(scopeFile) };
420
484
  }
421
485
  throw new WorkerError('block-scope-unreadable', 'the block scope cannot be read');
422
486
  }
@@ -445,42 +509,173 @@ function blockTicketOutcome(marker) {
445
509
  return 'unavailable';
446
510
  }
447
511
 
512
+ /** Completion timings are progress metadata; old markers remain readable.
513
+ * @param {Record<string, any> | null} value @returns {Record<string, any>} */
514
+ function ticketTimings(value) {
515
+ return Object.fromEntries(['queued_at', 'started_at', 'finished_at', 'duration_ms']
516
+ .filter((key) => value && Object.hasOwn(value, key)).map((key) => [key, value[key]]));
517
+ }
518
+
519
+ /** Restart only verified stopped reviews whose findings were not waived and whose bytes changed.
520
+ * Shared by the join and close; ordinary progress polling needs no signing key.
521
+ * @param {{repoRoot: string, run: string, block: string, tree: string, files: string[], print: (line: string) => unknown}} opts
522
+ * @returns {Promise<Map<string, string>>} restart timestamp by file
523
+ */
524
+ export async function requeueStoppedFiles({ repoRoot, run, block, tree, files, print }) {
525
+ const restarted = new Map();
526
+ if (files.length === 0) return restarted;
527
+ const record = await readRun(run);
528
+ const entry = record.blocks?.[block];
529
+ if (entry?.status !== 'open') return restarted;
530
+ if (realpathSync(await mainRootOf(record.workspace)) !== realpathSync(repoRoot)) throw new WorkerError('other-workspace', 'the run belongs to another workspace');
531
+ const recordedTree = (await blockTree(record.workspace, entry))?.top ?? repoRoot;
532
+ if (realpathSync(recordedTree) !== realpathSync(tree)) throw new WorkerError('worktree-mismatch', 'the block scope does not match the recorded content tree');
533
+ const owns = ownsAt(path.relative(realpathSync(repoRoot), realpathSync(record.workspace)).split(path.sep).join('/'));
534
+ const key = await loadKey(run);
535
+ const rows = (await readAllRows(record.project)).filter((row) => row.run === run);
536
+ const root = configInForce(record)?.tmp?.root;
537
+ const runRootDir = runRootPath(run, root == null ? {} : { root });
538
+ const where = realpathSync(tree) === realpathSync(repoRoot) ? {} : { tree: realpathSync(tree) };
539
+ const writeRow = (row) => appendRow(row, { slug: record.project });
540
+ for (const file of files) {
541
+ if (!owns(entry.owned_files, file, entry.owned_kinds) || reviewExcludeRule(configInForce(record), file) !== null) continue;
542
+ if (Object.entries(record.blocks).some(([id, other]) => id !== block && other.status === 'open' && owns(other.owned_files, file, other.owned_kinds))) continue;
543
+ const loaded = loadState({ runId: run, block, file, key, rows, runRootDir, contentRoot: tree, migrate: false });
544
+ if (!['ok', 'restored'].includes(loaded.status) || loaded.state?.status !== 'stopped' || !loaded.state.open?.length) continue;
545
+ const fromHash = stopContentHash(loaded.state, rows, { runId: run, block, file, key });
546
+ const currentHash = contentHash(tree, file);
547
+ if (fromHash === null || fromHash === currentHash) continue;
548
+ // A newer accepted review wins over a stopped snapshot. At the stopped hash, use the
549
+ // same waiver rules as the gate, including severity and signature checks.
550
+ const needsReview = (hash) => checkBlockReviews({ runId: run, block, key,
551
+ rows: rows.filter((row) => row.file === undefined || row.file === file),
552
+ files: [{ file, content_hash: hash }], proofFiles: [], highPaths: [],
553
+ }).refusals.some((value) => value.file === file && ['unreviewed', 'unsigned', 'not_approved', 'review_cap', 'autopilot_waiver_invalid'].includes(value.code));
554
+ if (!needsReview(currentHash) || !needsReview(fromHash)) continue;
555
+ const result = await reopenFile({ runId: run, id: block, file, reason: AUTO_REQUEUE_REASON,
556
+ record, rows, key, writeRow, writeSigned, automatic: true,
557
+ io: {
558
+ owns, loadState: () => loaded, contentHash: (f) => contentHash(tree, f),
559
+ clearDone: (f) => clearDone({ repoRoot, run, block, file: f, ...where }).removed,
560
+ enqueue: (f, row) => enqueue({ repoRoot, run, block, file: f, ...where, after: row.ts }).ticket,
561
+ },
562
+ });
563
+ restarted.set(file, result.row.ts);
564
+ if (!result.existing) print(`auto-requeue ${file}: content changed after stopped review (ticket ${result.ticket})\n`);
565
+ }
566
+ return restarted;
567
+ }
568
+
448
569
  /**
449
- * 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
451
- * 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
570
+ * Ordinary block progress uses unsigned results. Restarting changed stopped findings verifies
571
+ * the durable state and writes a signed restart before queuing the new attempt.
572
+ * Re-scan metadata each poll and read content only after its stamp changes. Deliver unseen findings of the newest earlier ticket
573
+ * before rechecking a blind edit; the next join queues the fix. Changed stopped findings restart.
574
+ * @param {{repoRoot: string, cwd: string, block: string, run: string, maxMs: number, pollMs: number, say: (obj: Record<string, any>) => unknown, print: (line: string) => unknown, dropDone?: typeof dropDoneIf, now?: () => number, sleep?: (ms: number, signal?: AbortSignal) => Promise<unknown>}} opts
453
575
  */
454
- async function waitBlock({ repoRoot, cwd, block, run, maxMs, pollMs, say, dropDone = dropDoneIf }) {
455
- const { entry, tree } = await waitBlockScope(repoRoot, cwd, run, block);
576
+ async function waitBlock({ repoRoot, cwd, block, run, maxMs, pollMs, say, print, dropDone = dropDoneIf, now = Date.now, sleep = (ms, signal) => delay(ms, undefined, { signal }).catch((err) => { if (err.name !== 'AbortError') throw err; }) }) {
577
+ let scope = await waitBlockScope(repoRoot, cwd, run, block);
578
+ const { tree } = scope;
579
+ const gitPaths = waitGitPaths(tree);
456
580
  const where = realpathSync(tree) === realpathSync(repoRoot) ? {} : { tree: realpathSync(tree) };
457
- const deadline = Date.now() + maxMs;
581
+ const deadline = now() + maxMs;
582
+ let interval = pollMs;
583
+ let seen = '';
584
+ let treeStamp = '';
585
+ /** @type {string[]} */
586
+ let current = [];
587
+ /** @type {Map<string, {stamp: string, content_hash: string}>} */
588
+ const hashes = new Map();
458
589
  let downPolls = 0;
459
590
  const deliveredHere = new Set();
591
+ let changed = false;
592
+ /** @type {(() => void) | null} */
593
+ let wake = null;
594
+ /** @type {import('node:fs').FSWatcher | undefined} */
595
+ let watcher;
596
+ const notify = () => { changed = true; wake?.(); };
597
+ // Install before scanning so a completion between the scan and the wait is retained.
598
+ // Queue .done markers are already published after their results, on macOS and Linux.
599
+ try {
600
+ watcher = watch(queueDir(repoRoot), (_event, name) => {
601
+ if (name === null || String(name).endsWith('.done')) notify();
602
+ });
603
+ watcher.on('error', () => { watcher.close(); notify(); });
604
+ } catch { /* unavailable watches retain the existing backoff polling */ }
605
+ try {
460
606
  for (;;) {
607
+ changed = false;
608
+ scope = await waitBlockScope(repoRoot, cwd, run, block, scope);
609
+ // Keep one daemon join for the remaining deadline: it retains findings already
610
+ // delivered while other files are pending, just like deliveredHere below.
611
+ const answer = await daemonRequest(run, { method: 'wait-block', block, max_ms: Math.max(0, deadline - now()) }, { config: scope.cfg });
612
+ // A stopped review may need a signed automatic reopen. Keep that mutation
613
+ // on the existing Node path; ordinary pending/completed polls stay read-free.
614
+ if (answer && !answer.files.some((/** @type {Record<string, any>} */ file) => file.outcome === 'stopped')) {
615
+ if (answer.reason === 'wait_timeout' && now() < deadline) continue;
616
+ // Go serializes map keys alphabetically; retain the CLI's byte order.
617
+ const files = answer.files.map((/** @type {Record<string, any>} */ f) => ({
618
+ file: f.file, ticket: f.ticket, status: f.status, outcome: f.outcome,
619
+ ...(f.rule !== undefined ? { rule: f.rule } : {}), ...ticketTimings(f),
620
+ }));
621
+ say({ block, files, done: answer.done,
622
+ ...(answer.status !== undefined ? { status: answer.status } : {}),
623
+ ...(answer.reason !== undefined ? { reason: answer.reason } : {}),
624
+ ...(answer.next !== undefined ? { next: answer.next } : {}),
625
+ });
626
+ return answer.status === 'worker_down' ? 3 : 0;
627
+ }
461
628
  const worker = liveWorker(repoRoot);
462
629
  const live = worker?.run === run;
463
630
  downPolls = live ? 0 : downPolls + 1;
464
631
  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);
632
+ scope = await waitBlockScope(repoRoot, cwd, run, block, scope);
633
+ const { entry, cfg, project } = scope;
634
+ const stamp = waitTreeStamp(tree, entry.owned_files, gitPaths) + scope.stamp;
635
+ if (stamp !== treeStamp) {
636
+ current = (await changedFiles({ repoRoot: tree, base: entry.base_sha })).filter((file) => ownsFile(entry.owned_files, file, entry.owned_kinds));
637
+ treeStamp = stamp;
638
+ }
639
+ const stopped = current.filter((file) => {
640
+ const latest = tickets.filter((ticket) => ticket.file === file).at(-1);
641
+ return latest && blockTicketMarker(repoRoot, latest.ticket)?.status === 'stopped'
642
+ && doneResult(repoRoot, latest.ticket)?.findings?.length > 0;
643
+ });
644
+ const restarted = live ? await requeueStoppedFiles({ repoRoot, run, block, tree, files: stopped, print }) : new Map();
645
+ // Progress only, like answersPrecheck: sandboxed callers may not read the private ledger.
646
+ // Reopens issued here also renew the workspace ticket, which needs no private read to join.
647
+ let rows = [];
648
+ try { rows = await readAllRows(project); } catch { /* The worker verifies the signed row. */ }
649
+ const reopens = new Map();
650
+ for (const row of rows) {
651
+ if (row.event !== 'review.reopened' || row.run !== run || row.block !== block || !Number.isFinite(Date.parse(row.ts))) continue;
652
+ if (row.by !== 'human' && !(row.by === 'agent' && row.verdict?.verdict === 'agree') && !isAutomaticReopen(row)) continue;
653
+ if (!reopens.has(row.file) || row.ts > reopens.get(row.file)) reopens.set(row.file, row.ts);
654
+ }
655
+ for (const [file, ts] of restarted) reopens.set(file, ts);
656
+ const files = current.map((file) => {
657
+ const rule = reviewExcludeRule(cfg, file);
658
+ if (rule !== null) return { file, ticket: null, status: 'excluded', outcome: null, rule };
659
+ const after = reopens.get(file);
660
+ const stamp = fileStamp(path.join(tree, file));
661
+ if (hashes.get(file)?.stamp !== stamp) hashes.set(file, { stamp, content_hash: contentHash(tree, file) });
662
+ const opts = { repoRoot, run, block, file, ...where, after, content_hash: hashes.get(file).content_hash };
663
+ const { ticket } = resolveHashTicket(opts);
664
+ const previous = tickets.filter((t) => t.file === file && (after === undefined || Date.parse(t.enqueued_at) > Date.parse(after)));
470
665
  let present = previous.find((t) => t.ticket === ticket);
471
666
  if (!present && previous.length > 0) {
472
667
  const newest = previous[previous.length - 1];
473
668
  const marker = blockTicketMarker(repoRoot, newest.ticket);
474
- if (marker?.status === 'stopped') return { file, ticket: newest.ticket, status: 'done', outcome: blockTicketOutcome(marker) };
669
+ if (marker?.status === 'stopped') return { file, ticket: newest.ticket, status: 'done', outcome: blockTicketOutcome(marker), ...ticketTimings(marker) };
475
670
  if (marker?.status === 'reviewed' && marker.open_findings === true && live) {
476
671
  if (deliveredHere.has(newest.ticket) || deliverBlockFindings({ repoRoot, block, file, ticket: newest.ticket })) {
477
672
  deliveredHere.add(newest.ticket);
478
- return { file, ticket: newest.ticket, status: 'findings', outcome: blockTicketOutcome(marker) };
673
+ return { file, ticket: newest.ticket, status: 'findings', outcome: blockTicketOutcome(marker), ...ticketTimings(marker) };
479
674
  }
480
675
  }
481
676
  }
482
677
  if (!present && live) {
483
- enqueue(opts);
678
+ enqueueHash(opts);
484
679
  present = { ...previous[previous.length - 1], ticket };
485
680
  }
486
681
  let marker = present ? blockTicketMarker(repoRoot, ticket) : null;
@@ -488,7 +683,7 @@ async function waitBlock({ repoRoot, cwd, block, run, maxMs, pollMs, say, dropDo
488
683
  dropFreshDone({ repoRoot, ticket, file, dropDone, once: true, statuses: RETRY_STATUSES });
489
684
  marker = blockTicketMarker(repoRoot, ticket);
490
685
  }
491
- return { file, ticket: present ? ticket : null, status: !present ? 'not_queued' : marker ? 'done' : 'pending', outcome: blockTicketOutcome(marker) };
686
+ return { file, ticket: present ? ticket : null, status: !present ? 'not_queued' : marker ? 'done' : 'pending', outcome: blockTicketOutcome(marker), ...ticketTimings(marker) };
492
687
  });
493
688
  if (downPolls >= 2) {
494
689
  say({ block, files, done: false, status: 'worker_down' });
@@ -499,38 +694,72 @@ async function waitBlock({ repoRoot, cwd, block, run, maxMs, pollMs, say, dropDo
499
694
  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
695
  return 0;
501
696
  }
502
- if (live && files.every((f) => f.status === 'done' || f.status === 'findings')) {
697
+ if (live && files.every((f) => f.status === 'done' || f.status === 'findings' || f.status === 'excluded')) {
503
698
  say({ block, files, done: true });
504
699
  return 0;
505
700
  }
506
- if (live && (files.some((f) => f.status === 'not_queued') || Date.now() >= deadline)) {
701
+ if (live && (files.some((f) => f.status === 'not_queued') || now() >= deadline)) {
507
702
  say({ block, files, done: false, reason: files.some((f) => f.status === 'not_queued') ? 'not_queued' : 'wait_timeout' });
508
703
  return 0;
509
704
  }
510
- await new Promise((resolve) => setTimeout(resolve, pollMs));
705
+ const ticketIds = [...new Set([...tickets.map((t) => t.ticket), ...files.map((f) => f.ticket).filter(Boolean)])].sort();
706
+ const state = JSON.stringify([stamp, live, ticketIds, files]);
707
+ if (!changed) {
708
+ interval = seen === state ? Math.min(MAX_IDLE_MS, interval * 2) : pollMs;
709
+ seen = state;
710
+ const abort = new AbortController();
711
+ try {
712
+ const event = new Promise((resolve) => { wake = () => resolve(undefined); });
713
+ await Promise.race([event, sleep(Math.min(interval, Math.max(0, deadline - now())), abort.signal)]);
714
+ } finally { wake = null; abort.abort(); }
715
+ }
511
716
  }
717
+ } finally { watcher?.close(); }
512
718
  }
513
719
 
514
720
  /**
515
721
  * @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.
722
+ * @param {{stdout?: {write: (s: string) => unknown}, stderr?: {write: (s: string) => unknown}, cwd?: string, pollMs?: number, dropDone?: typeof dropDoneIf, now?: () => number, sleep?: (ms: number, signal?: AbortSignal) => Promise<unknown>}} [deps] - `dropDone`: B75's claim, for tests.
517
723
  * @returns {Promise<number>}
518
724
  */
519
725
  export async function runReviewFile(args, deps = {}) {
520
726
  const stdout = deps.stdout ?? process.stdout;
521
727
  const stderr = deps.stderr ?? process.stderr;
522
728
  const say = (/** @type {Record<string, any>} */ obj) => writeSafe(stdout, `${JSON.stringify(obj)}\n`);
523
- const cwd = deps.cwd ?? process.cwd();
524
729
  try {
525
- const { flags, positionals } = parseFlags(args, { values: ['block', 'run', 'wait', 'wait-block', 'max', 'answers', 'of'], booleans: ['fresh'] });
730
+ const { flags, positionals } = parseFlags(args, { values: ['cwd', 'block', 'run', 'wait', 'wait-block', 'max', 'answers', 'of'], booleans: ['fresh', 'rebase-check'] });
731
+ let cwd = deps.cwd ?? process.cwd();
732
+ if (typeof flags.cwd === 'string') {
733
+ cwd = path.resolve(cwd, flags.cwd);
734
+ try {
735
+ if (!statSync(cwd).isDirectory()) throw new Error('not a directory');
736
+ } catch {
737
+ throw new WorkerError('usage', '--cwd must name an existing directory');
738
+ }
739
+ }
526
740
  // the queue and the worker: the main tree, also from inside a block's worktree
527
741
  const repoRoot = await mainRootOf(cwd);
528
742
 
743
+ if (flags['rebase-check'] === true) {
744
+ if (positionals.length || typeof flags.block !== 'string' || typeof flags.run !== 'string' || ['wait', 'wait-block', 'max', 'answers', 'of', 'fresh'].some((key) => flags[key] !== undefined)) {
745
+ throw new WorkerError('usage', '--rebase-check takes --block and --run only');
746
+ }
747
+ const block = assertBlockId(flags.block);
748
+ const runId = assertRunId(flags.run);
749
+ const worker = liveWorker(repoRoot);
750
+ if (!worker || worker.run !== runId) { say({ status: 'worker_down', block }); return 3; }
751
+ const record = await readRun(runId);
752
+ if (await mainRootOf(record.workspace) !== repoRoot) throw new WorkerError('other-workspace', 'the run belongs to another workspace');
753
+ const result = await rebaseBlock({ runId, id: block, rebaseCheck: true, writeRow: (row) => appendRow(row, { slug: record.project }) });
754
+ say({ block, status: 'queued', ...result, next: `code-forge block rebase ${block} --run ${runId}` });
755
+ return 0;
756
+ }
757
+
529
758
  if (flags['wait-block'] !== undefined) {
530
759
  if (positionals.length > 0 || flags.block !== undefined || flags.wait !== undefined || flags.answers !== undefined || flags.of !== undefined || flags.fresh === true || typeof flags.run !== 'string') {
531
760
  throw new WorkerError('usage', '--wait-block takes a block, --run and --max only');
532
761
  }
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 });
762
+ 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, print: (line) => writeSafe(stderr, line), dropDone: deps.dropDone, now: deps.now, sleep: deps.sleep });
534
763
  }
535
764
 
536
765
  if (typeof flags.wait === 'string') {
@@ -542,7 +771,7 @@ export async function runReviewFile(args, deps = {}) {
542
771
  for (;;) {
543
772
  if (isDone(repoRoot, ticket)) {
544
773
  const result = doneResult(repoRoot, ticket);
545
- say({ ticket, status: 'done', result, ...fixList(result) });
774
+ say({ ticket, status: 'done', result, ...ticketTimings(result), ...fixList(result) });
546
775
  return 0;
547
776
  }
548
777
  downPolls = liveWorker(repoRoot) ? 0 : downPolls + 1;
@@ -561,9 +790,23 @@ export async function runReviewFile(args, deps = {}) {
561
790
  if (positionals.length !== 1 || typeof flags.block !== 'string' || flags.max !== undefined) throw new WorkerError('usage', 'give one path and --block');
562
791
  const block = assertBlockId(flags.block);
563
792
  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
793
  const file = normalizeRequestPath(positionals[0], cwd, await repoRootOf(cwd));
566
794
  const worker = liveWorker(repoRoot);
795
+ let cfg = null;
796
+ const requestedRun = typeof flags.run === 'string' ? flags.run : worker?.run;
797
+ if (requestedRun) {
798
+ try { cfg = configInForce(await readRun(requestedRun)); }
799
+ catch { /* The workspace mirror below also serves sandboxed callers. */ }
800
+ }
801
+ const candidates = [file, ...(typeof flags.of === 'string' ? [normalizeRequestPath(flags.of, cwd, await repoRootOf(cwd))] : [])];
802
+ for (const candidate of candidates) {
803
+ const rule = requestedRun ? reviewExclusionFor(repoRoot, requestedRun, candidate, cfg) : reviewExcludeRule(cfg, candidate);
804
+ if (rule !== null) {
805
+ writeSafe(stdout, `not reviewed (review.exclude ${rule}): ${candidate}\n`);
806
+ return 0;
807
+ }
808
+ }
809
+ if (flags.answers !== undefined || flags.of !== undefined) return await enqueueAnswersTicket({ flags, answering: positionals[0], block, cwd, repoRoot, say });
567
810
  if (!worker || (typeof flags.run === 'string' && flags.run !== worker.run)) {
568
811
  say({ status: 'worker_down', file });
569
812
  return 3;
@@ -574,12 +817,6 @@ export async function runReviewFile(args, deps = {}) {
574
817
  const tree = recorded === null ? await repoRootOf(cwd) : (recorded.top ?? repoRoot);
575
818
  assertInsideRoot(tree, file); // the path, re-checked in the tree its content is read from
576
819
  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
820
  if (flags.fresh === true) return freshTicket({ repoRoot, run: worker.run, block, file, where, say, cfg, stderr, ...(deps.dropDone ? { dropDone: deps.dropDone } : {}) });
584
821
  const queued = enqueue({ repoRoot, run: worker.run, block, file, ...where });
585
822
  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 });
@@ -54,9 +54,11 @@ import { budgetUsdOf } from '../ledger/spend.mjs';
54
54
  import { readAllRows } from '../ledger/write.mjs';
55
55
  import { findingsKey, openFindingsOf } from '../review/open-findings.mjs';
56
56
  import { briefStatus } from '../state/brief-status.mjs';
57
+ import { configInForce } from '../state/config-snapshot.mjs';
57
58
  import { parseFlags } from '../state/cli-args.mjs';
58
59
  import { runsDir, StateError } from '../state/paths.mjs';
59
60
  import { readRun } from '../state/run.mjs';
61
+ import { daemonRequest } from '../util/native.mjs';
60
62
  import { writeSafe } from '../util/redact.mjs';
61
63
 
62
64
  const USAGE = 'usage: code-forge status [--run <id>] [--json]\n';
@@ -80,15 +82,18 @@ async function realOr(p) {
80
82
  /**
81
83
  * The status data of one run.
82
84
  * @param {Record<string, any>} record
85
+ * @param {Array<Record<string, any>> | null | undefined} [cachedRows] - daemon's incrementally read ledger
83
86
  * @returns {Promise<RunStatus>}
84
87
  */
85
- async function statusOf(record) {
88
+ export async function statusOf(record, cachedRows) {
86
89
  /** @type {Array<Record<string, any>> | null} null: the ledger cannot be read */
87
- let rows = null;
88
- try {
89
- rows = await readAllRows(record.project);
90
- } catch {
91
- rows = null;
90
+ let rows = cachedRows ?? null;
91
+ if (cachedRows === undefined) {
92
+ try {
93
+ rows = await readAllRows(record.project);
94
+ } catch {
95
+ rows = null;
96
+ }
92
97
  }
93
98
  let budget = null;
94
99
  try {
@@ -113,6 +118,22 @@ async function statusOf(record) {
113
118
  ...(awaiting.length > 0 ? { awaiting_human_check: awaiting } : {}) };
114
119
  }
115
120
 
121
+ /** Restore the Node renderer's insertion order after Go's sorted JSON map encoding.
122
+ * @param {Record<string, any>} s @returns {RunStatus}
123
+ */
124
+ function daemonStatus(s) {
125
+ return {
126
+ run_id: s.run_id, status: s.status, mode: s.mode, spent_usd: s.spent_usd,
127
+ unknown_usd_sessions: s.unknown_usd_sessions, budget_usd: s.budget_usd,
128
+ blocks: s.blocks.map((/** @type {Record<string, any>} */ b) => ({
129
+ id: b.id, state: b.state, level: b.level, model: b.model,
130
+ gate: { result: b.gate.result, name: b.gate.name }, findings: b.findings,
131
+ })),
132
+ more: s.more, closed: s.closed, stopped: s.stopped,
133
+ ...(s.awaiting_human_check ? { awaiting_human_check: s.awaiting_human_check } : {}),
134
+ };
135
+ }
136
+
116
137
  /**
117
138
  * Every run that is not ended whose workspace is `root`, newest first.
118
139
  * @param {string} root
@@ -155,7 +176,7 @@ export function runLines(s) {
155
176
  const lines = [`run ${s.run_id} · ${s.status ?? '-'} · ${s.mode} · spent ${spent}${s.budget_usd !== null ? ` of ${usd(s.budget_usd)}` : ''}`];
156
177
  const tally = s.closed + s.stopped > 0 ? `closed ${s.closed} · stopped ${s.stopped}` : null;
157
178
  const awaiting = s.awaiting_human_check?.length ? `awaiting_human_check: ${JSON.stringify(s.awaiting_human_check)}` : null;
158
- const room = STATUS_MAX_LINES - 1 - (tally === null ? 0 : 1) - (awaiting === null ? 0 : 1);
179
+ const room = STATUS_MAX_LINES - lines.length - (tally === null ? 0 : 1) - (awaiting === null ? 0 : 1);
159
180
  const shown = s.blocks.length <= room ? s.blocks : s.blocks.slice(0, room - 1);
160
181
  for (const b of shown) {
161
182
  lines.push(`${b.id} ${b.state} ${b.level ?? '-'} · ${b.model ?? '-'} · gate ${b.gate.result}${b.gate.name ? ` (${b.gate.name})` : ''} · findings ${b.findings}`);
@@ -198,9 +219,22 @@ export default async function status(args) {
198
219
  records = await activeRunsFor(projectRootFor(process.cwd()));
199
220
  }
200
221
  const shown = [];
201
- for (const record of records) shown.push(await statusOf(record));
222
+ for (const record of records) {
223
+ let config;
224
+ try {
225
+ config = configInForce(record);
226
+ if (!config) {
227
+ const loaded = await loadProjectConfig(record.workspace);
228
+ config = loaded.ok ? loaded.config : { native: { daemon: false } };
229
+ }
230
+ } catch { config = { native: { daemon: false } }; }
231
+ const answer = await daemonRequest(String(record.run_id), { method: 'status' }, { config });
232
+ let summary;
233
+ try { summary = answer ? daemonStatus(answer.runs[0]) : null; } catch { summary = null; }
234
+ shown.push(summary ?? await statusOf(record));
235
+ }
202
236
  if (json) {
203
- out(`${JSON.stringify({ runs: shown })}\n`); // briefStatus builds exactly the clause-3 run object
237
+ out(`${JSON.stringify({ runs: shown })}\n`);
204
238
  return 0;
205
239
  }
206
240
  if (shown.length === 0) {
@@ -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
  }