@codedology/code-forge 0.15.3 → 0.16.0

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 (56) hide show
  1. package/CHANGELOG.md +51 -0
  2. package/README.md +1 -1
  3. package/docs/reference/blocks.json +9 -1
  4. package/docs/reference/config.md +11 -1
  5. package/native/README.md +10 -0
  6. package/native/bin/darwin-arm64/code-forge-native +0 -0
  7. package/native/bin/darwin-x64/code-forge-native +0 -0
  8. package/native/bin/linux-arm64/code-forge-native +0 -0
  9. package/native/bin/linux-x64/code-forge-native +0 -0
  10. package/package.json +7 -2
  11. package/schema/code-forge.schema.json +26 -1
  12. package/schema/facts.schema.json +1 -1
  13. package/skill/SKILL.md +1 -1
  14. package/skill/references/adapters/solo.md +1 -1
  15. package/skill/references/autopilot.md +6 -1
  16. package/skill/references/code.md +37 -0
  17. package/skill/references/decisions.md +1 -1
  18. package/skill/references/plan.md +18 -5
  19. package/skill/references/review.md +47 -9
  20. package/skill/scripts/forge +1 -1
  21. package/skill/templates/facts-sheet.md +9 -3
  22. package/src/cli/block.mjs +284 -11
  23. package/src/cli/doctor.mjs +32 -1
  24. package/src/cli/help.mjs +1 -0
  25. package/src/cli/native-status.mjs +13 -0
  26. package/src/cli/review-file.mjs +126 -14
  27. package/src/cli/spawn.mjs +26 -22
  28. package/src/cli/status.mjs +80 -14
  29. package/src/decide/escalation.mjs +5 -5
  30. package/src/doctor/index.mjs +11 -0
  31. package/src/doctor/worker-probe.mjs +1 -1
  32. package/src/gates/dependents.mjs +200 -0
  33. package/src/gates/expand.mjs +83 -26
  34. package/src/gates/run.mjs +76 -14
  35. package/src/ledger/tail.mjs +89 -0
  36. package/src/review/agent-waiver.mjs +95 -8
  37. package/src/review/definitions.mjs +170 -0
  38. package/src/review/fixloop.mjs +4 -4
  39. package/src/review/gate-check.mjs +33 -13
  40. package/src/review/model-stats.mjs +125 -0
  41. package/src/review/packet.mjs +111 -22
  42. package/src/review/reopen.mjs +38 -13
  43. package/src/review/route.mjs +58 -4
  44. package/src/session/facts.mjs +252 -70
  45. package/src/session/plan-check.mjs +4 -1
  46. package/src/session/spawn.mjs +70 -19
  47. package/src/state/block-end.mjs +3 -1
  48. package/src/state/block.mjs +56 -21
  49. package/src/state/run.mjs +12 -5
  50. package/src/util/native.mjs +133 -0
  51. package/src/worker/engine.mjs +11 -26
  52. package/src/worker/loop.mjs +150 -20
  53. package/src/worker/queue.mjs +3 -2
  54. package/src/worker/review-state.mjs +15 -3
  55. package/src/worker/watch.mjs +97 -83
  56. package/src/worker/worker-warning.mjs +35 -0
@@ -67,11 +67,14 @@
67
67
  * needs its own review.
68
68
  */
69
69
 
70
- import { lstatSync, readdirSync, readFileSync, realpathSync, rmSync, statSync } 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, enqueueHash, isDone, liveWorker, MAX_ANSWERS, queueDir, resolveHashTicket, resolveTicket, reviewExclusionFor } 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 { changedFiles, 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';
@@ -79,12 +82,15 @@ 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';
83
87
  import { rebaseBlock } from '../state/block.mjs';
84
88
  import { configInForce } from '../state/config-snapshot.mjs';
85
89
  import { reviewExcludeRule } from '../config/validate.mjs';
86
90
  import { blockTree } from '../proof/worktree.mjs';
87
91
  import { writeSafe } from '../util/redact.mjs';
92
+ import { daemonRequest } from '../util/native.mjs';
93
+ import { runRootPath } from '../util/tmp.mjs';
88
94
 
89
95
  const USAGE =
90
96
  'usage: code-forge review-file [--cwd <dir>] <path> --block <id> [--run <id>] [--fresh] | review-file --wait <ticket> [--max <seconds>s]\n' +
@@ -510,13 +516,64 @@ function ticketTimings(value) {
510
516
  .filter((key) => value && Object.hasOwn(value, key)).map((key) => [key, value[key]]));
511
517
  }
512
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
+
513
569
  /**
514
- * A block wait uses unsigned results only to classify retries; it never reads the signing key.
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.
515
572
  * Re-scan metadata each poll and read content only after its stamp changes. Deliver unseen findings of the newest earlier ticket
516
- * before rechecking a blind edit; the next join queues the fix. Stopped files stay finished.
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
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
518
575
  */
519
- async function waitBlock({ repoRoot, cwd, block, run, maxMs, pollMs, say, dropDone = dropDoneIf, now = Date.now, sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)) }) {
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; }) }) {
520
577
  let scope = await waitBlockScope(repoRoot, cwd, run, block);
521
578
  const { tree } = scope;
522
579
  const gitPaths = waitGitPaths(tree);
@@ -531,7 +588,43 @@ async function waitBlock({ repoRoot, cwd, block, run, maxMs, pollMs, say, dropDo
531
588
  const hashes = new Map();
532
589
  let downPolls = 0;
533
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 {
534
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
+ }
535
628
  const worker = liveWorker(repoRoot);
536
629
  const live = worker?.run === run;
537
630
  downPolls = live ? 0 : downPolls + 1;
@@ -543,6 +636,12 @@ async function waitBlock({ repoRoot, cwd, block, run, maxMs, pollMs, say, dropDo
543
636
  current = (await changedFiles({ repoRoot: tree, base: entry.base_sha })).filter((file) => ownsFile(entry.owned_files, file, entry.owned_kinds));
544
637
  treeStamp = stamp;
545
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();
546
645
  // Progress only, like answersPrecheck: sandboxed callers may not read the private ledger.
547
646
  // Reopens issued here also renew the workspace ticket, which needs no private read to join.
548
647
  let rows = [];
@@ -550,9 +649,10 @@ async function waitBlock({ repoRoot, cwd, block, run, maxMs, pollMs, say, dropDo
550
649
  const reopens = new Map();
551
650
  for (const row of rows) {
552
651
  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;
652
+ if (row.by !== 'human' && !(row.by === 'agent' && row.verdict?.verdict === 'agree') && !isAutomaticReopen(row)) continue;
554
653
  if (!reopens.has(row.file) || row.ts > reopens.get(row.file)) reopens.set(row.file, row.ts);
555
654
  }
655
+ for (const [file, ts] of restarted) reopens.set(file, ts);
556
656
  const files = current.map((file) => {
557
657
  const rule = reviewExcludeRule(cfg, file);
558
658
  if (rule !== null) return { file, ticket: null, status: 'excluded', outcome: null, rule };
@@ -594,6 +694,11 @@ async function waitBlock({ repoRoot, cwd, block, run, maxMs, pollMs, say, dropDo
594
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.` });
595
695
  return 0;
596
696
  }
697
+ const unavailable = files.find((f) => f.outcome === 'unavailable');
698
+ if (live && unavailable) {
699
+ say({ block, files, done: false, reason: 'review_unavailable', next: `review-file ${unavailable.file} --fresh re-runs it; if --fresh refuses, block reopen ${block} --run ${run} --file ${unavailable.file} --reason <text>` });
700
+ return 0;
701
+ }
597
702
  if (live && files.every((f) => f.status === 'done' || f.status === 'findings' || f.status === 'excluded')) {
598
703
  say({ block, files, done: true });
599
704
  return 0;
@@ -604,15 +709,22 @@ async function waitBlock({ repoRoot, cwd, block, run, maxMs, pollMs, say, dropDo
604
709
  }
605
710
  const ticketIds = [...new Set([...tickets.map((t) => t.ticket), ...files.map((f) => f.ticket).filter(Boolean)])].sort();
606
711
  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())));
712
+ if (!changed) {
713
+ interval = seen === state ? Math.min(MAX_IDLE_MS, interval * 2) : pollMs;
714
+ seen = state;
715
+ const abort = new AbortController();
716
+ try {
717
+ const event = new Promise((resolve) => { wake = () => resolve(undefined); });
718
+ await Promise.race([event, sleep(Math.min(interval, Math.max(0, deadline - now())), abort.signal)]);
719
+ } finally { wake = null; abort.abort(); }
720
+ }
610
721
  }
722
+ } finally { watcher?.close(); }
611
723
  }
612
724
 
613
725
  /**
614
726
  * @param {string[]} args
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.
727
+ * @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.
616
728
  * @returns {Promise<number>}
617
729
  */
618
730
  export async function runReviewFile(args, deps = {}) {
@@ -652,7 +764,7 @@ export async function runReviewFile(args, deps = {}) {
652
764
  if (positionals.length > 0 || flags.block !== undefined || flags.wait !== undefined || flags.answers !== undefined || flags.of !== undefined || flags.fresh === true || typeof flags.run !== 'string') {
653
765
  throw new WorkerError('usage', '--wait-block takes a block, --run and --max only');
654
766
  }
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 });
767
+ 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 });
656
768
  }
657
769
 
658
770
  if (typeof flags.wait === 'string') {
package/src/cli/spawn.mjs CHANGED
@@ -1,9 +1,10 @@
1
1
  /**
2
2
  * `code-forge spawn --level L<n> --role <role> --brief <file> [--schema <file>] [--cwd <dir>]
3
- * [--run <id>] [--block <id>] [--timeout <s>] [--background]` (plan §5.2; block B9a).
3
+ * [--run <id>] [--block <id>] [--timeout <s>] [--background] [--print-argv]` (plan §5.2; block B9a).
4
4
  *
5
5
  * Runs one isolated session through `src/session/spawn.mjs` and prints its result as one JSON line
6
6
  * on stdout; the `level=… provider=… model=… effort=… fallback_step=…` line goes to stderr.
7
+ * With `--print-argv`, prints the first step's argv and metadata instead of launching a session.
7
8
  * Exit codes: 0 ok / started, 1 failed (timeout, bad output, refused), 2 usage, 3 unavailable
8
9
  * (every step of the ladder reported unavailability).
9
10
  *
@@ -17,7 +18,7 @@
17
18
  import { readFile } from 'node:fs/promises';
18
19
  import path from 'node:path';
19
20
  import { loadProjectConfig, projectRootFor, slugFor } from '../config/load.mjs';
20
- import { ROLES, runRootFor, SessionError, spawnSession } from '../session/spawn.mjs';
21
+ import { previewArgv, ROLES, runRootFor, SessionError, spawnSession } from '../session/spawn.mjs';
21
22
  import { intFlag, parseFlags } from '../state/cli-args.mjs';
22
23
  import { StateError } from '../state/paths.mjs';
23
24
  import { readRun } from '../state/run.mjs';
@@ -43,7 +44,7 @@ async function readJSONFlag(file, flag) {
43
44
  }
44
45
  }
45
46
 
46
- const USAGE = `usage: code-forge spawn --level L0|L1|L2|L3 --role ${ROLES.join('|')} --brief <file> [--schema <file>] [--cwd <dir>] [--run <id>] [--block <id>] [--timeout <seconds>] [--background]\n`;
47
+ const USAGE = `usage: code-forge spawn --level L0|L1|L2|L3 --role ${ROLES.join('|')} --brief <file> [--schema <file>] [--cwd <dir>] [--run <id>] [--block <id>] [--timeout <seconds>] [--background] [--print-argv]\n`;
47
48
 
48
49
  /**
49
50
  * @param {{status: string}} result
@@ -93,8 +94,8 @@ export async function runSpawn(args, deps = {}) {
93
94
  const stdout = deps.stdout ?? process.stdout;
94
95
  const stderr = deps.stderr ?? process.stderr;
95
96
  try {
96
- const { flags, positionals } = parseFlags(args, { values: ['level', 'role', 'brief', 'schema', 'cwd', 'run', 'block', 'timeout'], booleans: ['background'] });
97
- if (positionals.length > 0 || typeof flags.level !== 'string' || typeof flags.role !== 'string' || typeof flags.brief !== 'string') {
97
+ const { flags, positionals } = parseFlags(args, { values: ['level', 'role', 'brief', 'schema', 'cwd', 'run', 'block', 'timeout'], booleans: ['background', 'print-argv'] });
98
+ if (positionals.length > 0 || typeof flags.level !== 'string' || typeof flags.role !== 'string' || typeof flags.brief !== 'string' || (flags['print-argv'] && flags.background)) {
98
99
  writeSafe(stderr, USAGE);
99
100
  return 2;
100
101
  }
@@ -111,23 +112,26 @@ export async function runSpawn(args, deps = {}) {
111
112
  const runRootDir = typeof flags.run === 'string' ? runRootFor(flags.run, loaded.config?.tmp?.root) : undefined;
112
113
  // B93: only a coder reads coder.sandbox, so only a coder session is held to the run-start value
113
114
  const cfg = typeof flags.run === 'string' && flags.role === 'coder' ? await checkRunSandbox(loaded.config, flags.run) : loaded.config;
114
- const result = await spawnSession(
115
- {
116
- cfg,
117
- level: /** @type {any} */ (flags.level),
118
- role: /** @type {any} */ (flags.role),
119
- promptPath: path.resolve(flags.brief),
120
- cwd: typeof flags.cwd === 'string' ? path.resolve(flags.cwd) : undefined,
121
- schema,
122
- timeoutMs: timeout === undefined ? undefined : timeout * 1000,
123
- background: flags.background === true,
124
- runRoot: runRootDir,
125
- run: typeof flags.run === 'string' ? flags.run : undefined,
126
- block: typeof flags.block === 'string' ? flags.block : undefined,
127
- slug: slugFor(cfg, root),
128
- },
129
- { ...deps, stderr },
130
- );
115
+ const opts = {
116
+ cfg,
117
+ level: /** @type {any} */ (flags.level),
118
+ role: /** @type {any} */ (flags.role),
119
+ promptPath: path.resolve(flags.brief),
120
+ cwd: typeof flags.cwd === 'string' ? path.resolve(flags.cwd) : undefined,
121
+ schema,
122
+ timeoutMs: timeout === undefined ? undefined : timeout * 1000,
123
+ background: flags.background === true,
124
+ runRoot: runRootDir,
125
+ run: typeof flags.run === 'string' ? flags.run : undefined,
126
+ block: typeof flags.block === 'string' ? flags.block : undefined,
127
+ slug: slugFor(cfg, root),
128
+ };
129
+ if (flags['print-argv']) {
130
+ const preview = await previewArgv(opts, { ...deps, stderr });
131
+ writeSafe(stdout, `${JSON.stringify(preview)}\n`);
132
+ return 0;
133
+ }
134
+ const result = await spawnSession(opts, { ...deps, stderr });
131
135
  const { text: _text, ...shown } = /** @type {Record<string, any>} */ (result);
132
136
  writeSafe(stdout, `${JSON.stringify(shown)}\n`);
133
137
  return exitCodeFor(result);
@@ -15,11 +15,12 @@
15
15
  *
16
16
  * Text: at most {@link STATUS_MAX_LINES} lines per run, runs separated by one blank line:
17
17
  * run <id> · <status> · <plan|brief> · spent $<x>[ of $<budget>]
18
- * <id> <state> <level> · <model or -> · gate <pass|fail|none>[ (<gate>)] · findings <n>
18
+ * <id> <state> <level> · <model or -> · gate <pass|fail|timeout|none>[ (<gate>)] · findings <n>
19
19
  * … +<k> more blocks (only when the open blocks do not fit)
20
20
  * closed <a> · stopped <b> (only when any exist; printed last, but block lines are
21
21
  * dropped first to keep it inside the cap)
22
- * The gate field is `gate pass`, `gate fail (<gate>)`, `gate fail` (no gate named) or `gate none`.
22
+ * The gate field is `gate pass`, `gate fail (<gate>)`, `gate timeout (<gate>)`, `gate fail`
23
+ * (no gate named) or `gate none`.
23
24
  * `<x>` is `run status`'s spent_usd with 2 decimals, followed by `+?` when some session has no
24
25
  * known price (`run status`'s unknown_usd_sessions > 0); the whole amount is `$?` when the ledger
25
26
  * cannot be read. A run record with no status shows `-`.
@@ -54,9 +55,12 @@ import { budgetUsdOf } from '../ledger/spend.mjs';
54
55
  import { readAllRows } from '../ledger/write.mjs';
55
56
  import { findingsKey, openFindingsOf } from '../review/open-findings.mjs';
56
57
  import { briefStatus } from '../state/brief-status.mjs';
58
+ import { configInForce } from '../state/config-snapshot.mjs';
57
59
  import { parseFlags } from '../state/cli-args.mjs';
58
60
  import { runsDir, StateError } from '../state/paths.mjs';
59
61
  import { readRun } from '../state/run.mjs';
62
+ import { unpinnedWorkerWarnings } from '../worker/worker-warning.mjs';
63
+ import { daemonRequest } from '../util/native.mjs';
60
64
  import { writeSafe } from '../util/redact.mjs';
61
65
 
62
66
  const USAGE = 'usage: code-forge status [--run <id>] [--json]\n';
@@ -65,7 +69,8 @@ const USAGE = 'usage: code-forge status [--run <id>] [--json]\n';
65
69
  const STATUS_MAX_LINES = 10;
66
70
 
67
71
  /**
68
- * @typedef {import('../state/brief-status.mjs').RunStatus & {awaiting_human_check?: string[]}} RunStatus
72
+ * @typedef {Omit<import('../state/brief-status.mjs').BlockStatus, 'gate'> & {gate: {result: 'pass'|'fail'|'timeout'|'none', name: string | null}}} BlockStatus
73
+ * @typedef {Omit<import('../state/brief-status.mjs').RunStatus, 'blocks'> & {blocks: BlockStatus[], awaiting_human_check?: string[]}} RunStatus
69
74
  */
70
75
 
71
76
  /** @param {string} p @returns {Promise<string>} */
@@ -77,18 +82,46 @@ async function realOr(p) {
77
82
  }
78
83
  }
79
84
 
85
+ /** Recover timeout reasons from the last gate run, including older rows without timed_out.
86
+ * @param {RunStatus} summary @param {Array<Record<string, any>> | null} rows
87
+ * @returns {RunStatus}
88
+ */
89
+ function timeoutStatus(summary, rows) {
90
+ const pending = new Set(), timedOut = new Set();
91
+ for (const row of rows ?? []) {
92
+ if (row.run !== summary.run_id || typeof row.block !== 'string') continue;
93
+ if (row.event === 'gate.red') {
94
+ if (row.reason === 'timeout' || row.timed_out === true) {
95
+ pending.add(row.block);
96
+ timedOut.add(row.block);
97
+ } else {
98
+ pending.delete(row.block);
99
+ timedOut.delete(row.block);
100
+ }
101
+ } else if (row.event === 'gate.done') {
102
+ if (row.all_ok === true || !pending.has(row.block)) timedOut.delete(row.block);
103
+ pending.delete(row.block);
104
+ }
105
+ }
106
+ return { ...summary, blocks: summary.blocks.map((block) => block.gate.result === 'fail' && timedOut.has(block.id)
107
+ ? { ...block, gate: { ...block.gate, result: 'timeout' } } : block) };
108
+ }
109
+
80
110
  /**
81
111
  * The status data of one run.
82
112
  * @param {Record<string, any>} record
113
+ * @param {Array<Record<string, any>> | null | undefined} [cachedRows] - daemon's incrementally read ledger
83
114
  * @returns {Promise<RunStatus>}
84
115
  */
85
- async function statusOf(record) {
116
+ export async function statusOf(record, cachedRows) {
86
117
  /** @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;
118
+ let rows = cachedRows ?? null;
119
+ if (cachedRows === undefined) {
120
+ try {
121
+ rows = await readAllRows(record.project);
122
+ } catch {
123
+ rows = null;
124
+ }
92
125
  }
93
126
  let budget = null;
94
127
  try {
@@ -109,8 +142,24 @@ async function statusOf(record) {
109
142
  else byBlock.set(r.block, [r]);
110
143
  }
111
144
  const awaiting = Object.keys(record.blocks ?? {}).filter((id) => record.blocks[id].status === 'awaiting_human_check').sort();
112
- return { ...briefStatus({ record, rows, budget, findingsOf: (block) => openFindingsOf({ block, runId, mine: byBlock.get(block) ?? [], rows: all, key }) }),
113
- ...(awaiting.length > 0 ? { awaiting_human_check: awaiting } : {}) };
145
+ return timeoutStatus({ ...briefStatus({ record, rows, budget, findingsOf: (block) => openFindingsOf({ block, runId, mine: byBlock.get(block) ?? [], rows: all, key }) }),
146
+ ...(awaiting.length > 0 ? { awaiting_human_check: awaiting } : {}) }, rows);
147
+ }
148
+
149
+ /** Restore the Node renderer's insertion order after Go's sorted JSON map encoding.
150
+ * @param {Record<string, any>} s @returns {RunStatus}
151
+ */
152
+ function daemonStatus(s) {
153
+ return {
154
+ run_id: s.run_id, status: s.status, mode: s.mode, spent_usd: s.spent_usd,
155
+ unknown_usd_sessions: s.unknown_usd_sessions, budget_usd: s.budget_usd,
156
+ blocks: s.blocks.map((/** @type {Record<string, any>} */ b) => ({
157
+ id: b.id, state: b.state, level: b.level, model: b.model,
158
+ gate: { result: b.gate.result, name: b.gate.name }, findings: b.findings,
159
+ })),
160
+ more: s.more, closed: s.closed, stopped: s.stopped,
161
+ ...(s.awaiting_human_check ? { awaiting_human_check: s.awaiting_human_check } : {}),
162
+ };
114
163
  }
115
164
 
116
165
  /**
@@ -155,7 +204,7 @@ export function runLines(s) {
155
204
  const lines = [`run ${s.run_id} · ${s.status ?? '-'} · ${s.mode} · spent ${spent}${s.budget_usd !== null ? ` of ${usd(s.budget_usd)}` : ''}`];
156
205
  const tally = s.closed + s.stopped > 0 ? `closed ${s.closed} · stopped ${s.stopped}` : null;
157
206
  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);
207
+ const room = STATUS_MAX_LINES - lines.length - (tally === null ? 0 : 1) - (awaiting === null ? 0 : 1);
159
208
  const shown = s.blocks.length <= room ? s.blocks : s.blocks.slice(0, room - 1);
160
209
  for (const b of shown) {
161
210
  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 +247,26 @@ export default async function status(args) {
198
247
  records = await activeRunsFor(projectRootFor(process.cwd()));
199
248
  }
200
249
  const shown = [];
201
- for (const record of records) shown.push(await statusOf(record));
250
+ for (const record of records) {
251
+ let config;
252
+ try {
253
+ config = configInForce(record);
254
+ if (!config) {
255
+ const loaded = await loadProjectConfig(record.workspace);
256
+ config = loaded.ok ? loaded.config : { native: { daemon: false } };
257
+ }
258
+ } catch { config = { native: { daemon: false } }; }
259
+ const answer = await daemonRequest(String(record.run_id), { method: 'status' }, { config });
260
+ let summary;
261
+ try { summary = answer ? daemonStatus(answer.runs[0]) : null; } catch { summary = null; }
262
+ if (summary?.blocks.some((block) => block.gate.result === 'fail')) {
263
+ summary = timeoutStatus(summary, await readAllRows(record.project).catch(() => null));
264
+ }
265
+ shown.push(summary ?? await statusOf(record));
266
+ for (const warning of await unpinnedWorkerWarnings(record)) err(`${warning}\n`);
267
+ }
202
268
  if (json) {
203
- out(`${JSON.stringify({ runs: shown })}\n`); // briefStatus builds exactly the clause-3 run object
269
+ out(`${JSON.stringify({ runs: shown })}\n`);
204
270
  return 0;
205
271
  }
206
272
  if (shown.length === 0) {
@@ -220,13 +220,13 @@ export function runningCeiling(stopAt = 'L3') {
220
220
 
221
221
  /**
222
222
  * Rules 1, 2b and 2 share this: below the running ceiling ⇒ +1 level (never to L3); at the
223
- * running ceiling ⇒ rule 5, the block's one L3 rung (R1), then `stop`.
223
+ * running ceiling ⇒ rule 5, the file's one L3 rung (R1), then `stop`.
224
224
  * @param {'retries'|'review_rounds'|'review_stall'|'review_warnings'} trigger
225
225
  * @param {string} currentLevel
226
226
  * @param {string} stopAt
227
227
  * @param {'patch'|'code'} l3Mode
228
- * @param {boolean} l3RungAlreadyUsed - the L3 rung was already used once on THIS block, in
229
- * EITHER mode and from either rule 5 or rule 6 (one rung per block, not per trigger). A second
228
+ * @param {boolean} l3RungAlreadyUsed - the L3 rung was already used once on THIS file, in
229
+ * EITHER mode and from either rule 5 or rule 6 (one rung per file, not per trigger). A second
230
230
  * hit always stops.
231
231
  * @returns {EscalationResult}
232
232
  */
@@ -270,8 +270,8 @@ function finalizeEscalate(trigger, currentLevel, stopAt, l3Mode, l3RungAlreadyUs
270
270
  * @property {string} [stopAt] - `escalation.stop_at`, default `'L3'`.
271
271
  * @property {'patch'|'code'} [l3Mode] - `escalation.l3_mode`, default `'patch'` (R1).
272
272
  * @property {boolean} [l3RungAlreadyUsed] - the L3 rung (ceiling, rule 5, OR the file's review
273
- * cap, rule 6) was already used once before on THIS block, from either source. A second hit
274
- * always `stop`s — the plan's L3 rung is one per block, not one per trigger.
273
+ * cap, rule 6) was already used once before on THIS file, from either source. A second hit
274
+ * always `stop`s — the plan's L3 rung is one per file, not one per trigger.
275
275
  * @property {number} [roundsAtFile] - rule 6 (v1.3, §4.11): total review rounds run on this file
276
276
  * so far, counted across every level — unlike `reviewRoundsAtLevel`, this is never reset by a
277
277
  * level escalation.
@@ -11,6 +11,8 @@ import { mkdirSync, rmSync } from 'node:fs';
11
11
  import os from 'node:os';
12
12
  import path from 'node:path';
13
13
  import { cliNameForProvider } from '../engines/provider-cli.mjs';
14
+ import { readAllRows } from '../ledger/write.mjs';
15
+ import { modelStats, reviewerLines } from '../review/model-stats.mjs';
14
16
  import { newRunId } from '../state/paths.mjs';
15
17
  import { runRoot } from '../util/tmp.mjs';
16
18
  import { checkCommands, checkConfig, checkJev, checkKeys, checkLedger, checkLinks, checkLocks, checkReviewConcurrency, checkReviewState, checkTmp, checkTools } from './local.mjs';
@@ -53,6 +55,15 @@ export async function runDoctor(opts = {}, deps = {}) {
53
55
  const keys = await checkKeys(cfg, { env, store: deps.store, opRead: deps.opRead });
54
56
  /** @type {Row[]} */
55
57
  const rows = [...checked.rows, ...links, ...keys.rows];
58
+ if (cfg?.project?.slug) {
59
+ try {
60
+ const lines = reviewerLines(modelStats(await readAllRows(cfg.project.slug), cfg.review?.adaptive));
61
+ rows.push(...lines.map((line, i) => {
62
+ const at = line.indexOf(': ');
63
+ return row(`reviewer.${i}`, 'INFO', line.slice(0, at), line.slice(at + 2));
64
+ }));
65
+ } catch { /* a corrupt ledger is diagnosed by the existing ledger check */ }
66
+ }
56
67
  if (opts.quick) return rows;
57
68
 
58
69
  if (cfg) {
@@ -69,7 +69,7 @@ function removeRun(runId) {
69
69
  } catch {
70
70
  return;
71
71
  }
72
- for (const name of names) if (name === `${runId}.json` || name === `${runId}.key` || name === `${runId}.lock` || name.startsWith(`${runId}.json.`)) rmSync(path.join(runsDir(), name), { recursive: true, force: true });
72
+ for (const name of names) if (name === runId || name === `${runId}.json` || name === `${runId}.key` || name === `${runId}.lock` || name.startsWith(`${runId}.json.`)) rmSync(path.join(runsDir(), name), { recursive: true, force: true });
73
73
  }
74
74
 
75
75
  /**