@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
package/src/cli/block.mjs CHANGED
@@ -8,7 +8,8 @@
8
8
  * block attempt <id> --run <r>
9
9
  * block rebase <id> --run <r> [--head <ref>]
10
10
  * block claim <id> <path> --run <r>
11
- * block close <id> --run <r> [--worker-pid <pid>] [--remove-worktree]
11
+ * block close <id> --run <r> [--worker-pid <pid>] [--remove-worktree] [--auto-claim]
12
+ * block land <id> --run <r> --report <file> --transcript <file> [--max <dur>] [--merge] [--no-merge-gate] [--json]
12
13
  * block stop <id> --run <r> --reason <text>
13
14
  * block waive <id> <finding-id> --run <r> --file <path> --reason <text> [--yes] (orchestrator --yes: evidence and a fresh judge)
14
15
  * block reopen <id> --run <r> --file <path> --reason <text> [--yes] (B124c: evidence and a fresh judge for the agent path)
@@ -197,9 +198,12 @@
197
198
 
198
199
  import { createHash } from 'node:crypto';
199
200
  import { existsSync, readFileSync, statSync } from 'node:fs';
200
- import { readFile, realpath } from 'node:fs/promises';
201
+ import { mkdtemp, readFile, realpath, rm } from 'node:fs/promises';
201
202
  import path from 'node:path';
202
203
  import { parse as parseYAML } from 'yaml';
204
+ import { runGatesVerb } from './gates.mjs';
205
+ import { parseTapPassed } from '../gates/acceptance.mjs';
206
+ import { parseMax, requeueStoppedFiles, runReviewFile } from './review-file.mjs';
203
207
  import { approvalCheckText, expiryChecks } from '../autopilot/limits.mjs';
204
208
  import { fallbackLane } from '../decide/fallback-rules.mjs';
205
209
  import { laneFacts } from '../decide/lane-facts.mjs';
@@ -296,9 +300,236 @@ async function reopenBlockFile({ runId, id, file, reason, record, writeRow, agen
296
300
  return { row, cleared, ticket };
297
301
  }
298
302
 
303
+ /** Quote a displayed command for a POSIX shell; execution always uses argv.
304
+ * @param {string[]} argv @returns {string}
305
+ */
306
+ function landCommand(argv) {
307
+ return argv.map((arg) => /^[\w./:@=-]+$/.test(arg) ? arg : `'${arg.replaceAll("'", "'\\''")}'`).join(' ');
308
+ }
309
+
310
+ /** Claim only changes the normal scope check cannot account for, then join real review tickets.
311
+ * @param {{record: Record<string, any>, runId: string, id: string, writeRow: import('../state/run.mjs').WriteRow, print: (line: string) => void, join?: boolean}} opts
312
+ */
313
+ async function autoClaimChanges({ record, runId, id, writeRow, print, join = true }) {
314
+ const entry = record.blocks[id];
315
+ const wt = await blockTree(record.workspace, entry);
316
+ const mainTop = await repoTop(record.workspace);
317
+ const top = wt?.top ?? mainTop;
318
+ const prefix = path.relative(mainTop, await realpath(record.workspace)).split(path.sep).join('/');
319
+ const owns = ownsAt(prefix);
320
+ const ownState = new Set([path.posix.join(prefix, `.code-forge/runs/${runId}.json`), path.posix.join(prefix, `.code-forge/runs/${runId}/${id}.log`)]);
321
+ const changed = (await changedFiles({ repoRoot: top, base: wt?.base_sha ?? entry.base_sha })).filter((f) => !ownState.has(f));
322
+ const scope = await unownedChanges({ top, changed, id, blocks: record.blocks, rows: (await readAllRows(record.project)).filter((r) => r.run === runId), key: await loadKey(runId),
323
+ isMine: (file) => owns(entry.owned_files, file, entry.owned_kinds), owns, ownTree: wt !== null });
324
+ const files = [...new Set([...(scope?.unowned ?? []), ...(scope?.siblings.map((s) => s.file) ?? [])])].sort();
325
+ if (files.length === 0) return;
326
+ const invoke = async (/** @type {string[]} */ args) => {
327
+ let output = '', error = '';
328
+ const code = await runReviewFile(args, { cwd: top, stdout: { write: (s) => { output += s; } }, stderr: { write: (s) => { error += s; } } });
329
+ let data;
330
+ try { data = JSON.parse(output); } catch { /* Excluded files print a text line. */ }
331
+ if (code !== 0) throw new StateError('auto-claim-review', error || output || 'auto-claim review failed');
332
+ return { data, output };
333
+ };
334
+ for (const file of files) {
335
+ await claimPath({ runId, id, file, writeRow, auto: true });
336
+ Object.assign(entry, (await readRun(runId)).blocks[id]);
337
+ print(`block ${id} auto-claimed ${file}\n`);
338
+ await invoke([file, '--block', id, '--run', runId]);
339
+ }
340
+ if (!join) return; // Land joins all claimed and originally owned paths under one deadline.
341
+ const joined = await invoke(['--wait-block', id, '--run', runId]);
342
+ if (joined.data?.done !== true) throw new StateError('auto-claim-review', `auto-claim reviews did not finish: ${joined.output.trim()}`);
343
+ for (const file of joined.data.files) {
344
+ if (file.status === 'excluded' || file.outcome === 'approved') continue;
345
+ const detail = file.ticket ? (await invoke(['--wait', file.ticket])).output : file.outcome ?? file.status;
346
+ throw new StateError('auto-claim-review', `auto-claim review refused ${file.file}: ${detail}`);
347
+ }
348
+ }
349
+
350
+ /** Join, gates and close share their existing CLI handlers, without spawning the CLI.
351
+ * @param {{id: string, runId: string, record: Record<string, any>, report: string, transcript: string, maxMs: number, merge: boolean, noMergeGate: boolean, json: boolean}} options
352
+ * @param {NonNullable<Parameters<typeof runBlock>[1]>} deps
353
+ * @returns {Promise<number>}
354
+ */
355
+ async function landBlock({ id, runId, record, report, transcript, maxMs, merge, noMergeGate, json }, deps) {
356
+ const stdout = deps.stdout ?? process.stdout;
357
+ const stderr = deps.stderr ?? process.stderr;
358
+ const emit = (/** @type {Record<string, any>} */ value) => writeSafe(stdout, `${JSON.stringify(value)}\n`);
359
+ let step = 'join';
360
+ let next = landCommand(['code-forge', 'review-file', '--wait-block', id, '--run', runId]);
361
+ const stop = (/** @type {string} */ reason) => {
362
+ if (step === 'join' && reason === 'wait_timeout') {
363
+ next = landCommand(['code-forge', 'block', 'land', id, '--run', runId, '--report', report, '--transcript', transcript,
364
+ '--max', `${Math.max(1800, Math.ceil(maxMs / 500))}s`, ...(merge ? ['--merge'] : []), ...(noMergeGate ? ['--no-merge-gate'] : []), ...(json ? ['--json'] : [])]);
365
+ }
366
+ writeSafe(stderr, `block ${id} land stopped at ${step}: ${reason.trim()}\nnext: ${next}\n`);
367
+ if (json) emit({ block: id, event: 'refused', step, reason: reason.trim(), next });
368
+ return 1;
369
+ };
370
+ /** @param {(streams: {stdout: {write: (s: string) => void}, stderr: {write: (s: string) => void}}) => Promise<number>} run */
371
+ const capture = async (run) => {
372
+ let output = '';
373
+ let error = '';
374
+ const code = await run({ stdout: { write: (s) => { output += s; } }, stderr: { write: (s) => { error += s; } } });
375
+ /** @type {Record<string, any> | null} */
376
+ let data = null;
377
+ try { data = JSON.parse(output); } catch { /* A thrown refusal may only write stderr. */ }
378
+ return { code, output, error, data };
379
+ };
380
+ try {
381
+ const entry = record.blocks?.[id];
382
+ if (!entry) return stop(`unknown block in run ${runId}`);
383
+ if (entry.status !== 'open') return stop(`block is ${entry.status}, not open`);
384
+ const tree = await blockTree(record.workspace, entry);
385
+ if (!tree) return stop('block has no recorded worktree and branch');
386
+ const deadline = Date.now() + maxMs;
387
+ await autoClaimChanges({ record, runId, id, writeRow: (row) => appendRow(row, { slug: record.project }), print: (line) => writeSafe(json ? stderr : stdout, line), join: false });
388
+ let joined;
389
+ // Findings are usually final; cap retries so land does not wait the full --max on them.
390
+ for (let attempt = 0; attempt < 3; attempt += 1) {
391
+ const remaining = Math.max(0, deadline - Date.now());
392
+ if (remaining === 0) return stop('wait_timeout');
393
+ const duration = remaining > 9_999_999 ? `${Math.floor(remaining / 1000)}s` : `${remaining}ms`;
394
+ joined = await capture((streams) => runReviewFile(['--wait-block', id, '--run', runId, '--max', duration], { ...streams, cwd: tree.workspace }));
395
+ for (const line of joined.error.split('\n')) if (line.startsWith('auto-requeue ')) writeSafe(stderr, `${line}\n`);
396
+ if (joined.code !== 0 || !joined.data?.done || !joined.data.files.some((file) => file.status === 'findings')) break;
397
+ }
398
+ if (joined.code !== 0 || !joined.data?.done) {
399
+ if (joined.data?.status === 'wait_timeout' || joined.data?.reason === 'wait_timeout' || Date.now() >= deadline) return stop('wait_timeout');
400
+ return stop(joined.error || joined.data?.message || joined.data?.reason || joined.data?.status || 'reviews did not finish');
401
+ }
402
+ const problems = joined.data.files.filter((/** @type {Record<string, any>} */ file) => file.status !== 'excluded' && file.outcome !== 'approved');
403
+ if (problems.length > 0) {
404
+ const details = [];
405
+ for (const file of problems) {
406
+ details.push(`${file.file}: ${file.outcome ?? file.status}`);
407
+ if (file.ticket) {
408
+ const result = await capture((streams) => runReviewFile(['--wait', file.ticket], { ...streams, cwd: tree.workspace }));
409
+ details.push(result.output.trim() || result.error.trim());
410
+ }
411
+ }
412
+ next = landCommand(['code-forge', 'review-file', '--wait', problems[0].ticket]);
413
+ return stop(details.join('\n'));
414
+ }
415
+
416
+ step = 'gates';
417
+ next = landCommand(['code-forge', 'gates', 'run', '--run', runId, '--block', id, '--changed']);
418
+ const gates = await capture((streams) => runGatesVerb(['run', '--run', runId, '--block', id, '--changed', '--cwd', tree.workspace], streams));
419
+ if (gates.code !== 0 || gates.data?.allOk !== true) return stop(gates.error + (gates.output || 'gates did not pass'));
420
+ if (gates.error) writeSafe(stderr, gates.error);
421
+
422
+ step = 'close';
423
+ const closeArgs = ['close', id, '--run', runId, '--report', report, '--transcript', transcript];
424
+ next = landCommand(['code-forge', 'block', ...closeArgs]);
425
+ const closed = await capture((streams) => runBlock([...closeArgs, '--json'], { ...deps, ...streams }));
426
+ if (closed.code !== 0 || closed.data?.status !== 'closed') {
427
+ if (typeof closed.data?.next_command === 'string') next = closed.data.next_command;
428
+ return stop(closed.error || closed.data?.reason || closed.data?.status || 'close refused');
429
+ }
430
+ if (closed.error) writeSafe(stderr, closed.error);
431
+
432
+ step = 'merge';
433
+ const main = await repoTop(record.workspace);
434
+ if (typeof entry.base_branch !== 'string' || !entry.base_branch) return stop('block has no recorded base branch');
435
+ const mergeArgs = ['git', '-C', main, 'merge', '--no-ff', '--no-edit', '--', tree.branch];
436
+ const command = landCommand(mergeArgs);
437
+ next = command;
438
+ if (!merge) {
439
+ writeSafe(json ? stderr : stdout, 'hint: the merge gate runs only with --merge\n');
440
+ if (json) emit({ block: id, event: 'closed', merge: command });
441
+ else writeSafe(stdout, `block ${id} closed; merge: ${command}\n`);
442
+ return 0;
443
+ }
444
+ const gitOptions = { cwd: main, env: gitChildEnv(), timeoutMs: 120000 };
445
+ const branch = await exec(['git', 'symbolic-ref', '-q', 'HEAD'], gitOptions);
446
+ if (branch.result !== 'ok' || branch.stdout.trim() !== `refs/heads/${entry.base_branch}`) {
447
+ next = `${landCommand(['git', '-C', main, 'checkout', entry.base_branch])} && ${command}`;
448
+ return stop(`main checkout must be on recorded base branch ${entry.base_branch}`);
449
+ }
450
+ const status = await exec(['git', 'status', '--porcelain', '--untracked-files=all'], gitOptions);
451
+ if (status.result !== 'ok') return stop(status.stderr || 'cannot check main checkout');
452
+ if (status.stdout) {
453
+ next = landCommand(['git', '-C', main, 'status', '--short']);
454
+ return stop('main checkout is dirty; commit or stash its changes before merging');
455
+ }
456
+ // Close measures uncommitted content too; never call that content landed if git cannot merge it.
457
+ const blockStatus = await exec(['git', 'status', '--porcelain', '--untracked-files=all'], { ...gitOptions, cwd: tree.top });
458
+ if (blockStatus.result !== 'ok' || blockStatus.stdout) {
459
+ next = landCommand(['git', '-C', tree.top, 'status', '--short']);
460
+ return stop('block worktree is dirty or unreadable; commit and review its changes before merging');
461
+ }
462
+ if (noMergeGate || configInForce(record)?.gates?.merge_gate === false) {
463
+ writeSafe(stderr, 'warning: merge gate skipped; merged full suite was not checked\n');
464
+ } else {
465
+ step = 'merge-gate';
466
+ next = landCommand(['code-forge', 'gates', 'run', '--cwd', tree.workspace]);
467
+ const mainHead = await exec(['git', 'rev-parse', '--verify', 'HEAD'], gitOptions);
468
+ const blockHead = await exec(['git', 'rev-parse', '--verify', `refs/heads/${tree.branch}`], gitOptions);
469
+ if (mainHead.result !== 'ok' || blockHead.result !== 'ok') return stop('cannot resolve merge parents');
470
+ const started = Date.now();
471
+ // A sibling of this run's block worktree, never the process temp directory.
472
+ const temporary = await mkdtemp(path.join(path.dirname(tree.path), '.merge-gate-'));
473
+ let added = false;
474
+ let failure = '';
475
+ try {
476
+ // mkdtemp gives an empty directory; git worktree add accepts an existing empty path.
477
+ const created = await exec(['git', 'worktree', 'add', '--detach', temporary, mainHead.stdout.trim()], gitOptions);
478
+ added = created.result === 'ok';
479
+ if (!added) throw new Error(created.stderr || 'cannot create merge-gate worktree');
480
+ const merged = await exec(['git', 'merge', '--no-ff', '--no-edit', '--', blockHead.stdout.trim()], { ...gitOptions, cwd: temporary });
481
+ if (merged.result !== 'ok') {
482
+ const conflicts = await exec(['git', 'diff', '--name-only', '--diff-filter=U'], { ...gitOptions, cwd: temporary });
483
+ failure = conflicts.stdout.trim() ? `merge conflict:\n${conflicts.stdout.trim()}` : merged.stderr || merged.stdout || 'trial merge failed';
484
+ } else {
485
+ const workspace = path.join(temporary, path.relative(main, record.workspace));
486
+ // No block scope: the shared gate runner drops scope tokens and runs the full suite.
487
+ const suite = await capture((streams) => runGatesVerb(['run', '--cwd', workspace], streams));
488
+ if (suite.code !== 0 || suite.data?.allOk !== true) {
489
+ const results = suite.data?.results ?? [];
490
+ const output = results.filter((r) => !r.ok).map((r) => `${r.stdout}\n${r.stderr}`).join('\n');
491
+ const failed = [...parseTapPassed(output).failed].slice(0, 10);
492
+ failure = failed.length ? `full suite failed:\n${failed.join('\n')}`
493
+ : `full suite failed:\n${(suite.error || output || suite.output).trim().split('\n').slice(-20).join('\n')}`;
494
+ } else if (!suite.data.results.some((r) => r.gate === 'test' && !r.skipped)) {
495
+ failure = 'full suite unavailable: configure gates.test before merging';
496
+ }
497
+ }
498
+ } finally {
499
+ const removed = await exec(['git', 'worktree', 'remove', '--force', temporary], gitOptions);
500
+ if (added && removed.result !== 'ok') throw new Error(`cannot remove merge-gate worktree ${temporary}: ${removed.stderr}`);
501
+ await rm(temporary, { recursive: true, force: true });
502
+ }
503
+ if (failure) return stop(failure);
504
+ // Refuse a moving target: only merge the same parents whose combined suite passed.
505
+ const currentMain = await exec(['git', 'rev-parse', '--verify', 'HEAD'], gitOptions);
506
+ const currentBlock = await exec(['git', 'rev-parse', '--verify', `refs/heads/${tree.branch}`], gitOptions);
507
+ const currentBranch = await exec(['git', 'symbolic-ref', '-q', 'HEAD'], gitOptions);
508
+ const currentStatus = await exec(['git', 'status', '--porcelain', '--untracked-files=all'], gitOptions);
509
+ if (currentMain.result !== 'ok' || currentBlock.result !== 'ok' || currentBranch.result !== 'ok' || currentStatus.result !== 'ok'
510
+ || currentMain.stdout !== mainHead.stdout || currentBlock.stdout !== blockHead.stdout || currentBranch.stdout !== branch.stdout || currentStatus.stdout) {
511
+ return stop('merge parents or main checkout changed during the full suite; check the checkout and rerun the merge gate');
512
+ }
513
+ writeSafe(json ? stderr : stdout, `merge gate: full suite passed (${((Date.now() - started) / 1000).toFixed(1)}s)\n`);
514
+ }
515
+ step = 'merge';
516
+ next = command;
517
+ const merged = await exec(mergeArgs, gitOptions);
518
+ if (merged.result !== 'ok') {
519
+ next = landCommand(['git', '-C', main, 'status']);
520
+ return stop(merged.stderr || merged.stdout || 'git merge failed; resolve the merge before continuing');
521
+ }
522
+ if (json) emit({ block: id, event: 'landed', merge: command });
523
+ else writeSafe(stdout, `block ${id} landed\n`);
524
+ return 0;
525
+ } catch (thrown) {
526
+ return stop(thrown?.message ?? String(thrown));
527
+ }
528
+ }
529
+
299
530
  const USAGE =
300
531
  'usage: code-forge block open <id> --run <r> [--level L<n>] --owned <paths…> --acceptance <file> [--brief <file>] [--attempt <n>] [--base <sha>] [--lines <n>] [--kind code|docs|contract|light] [--tests <paths…>] [--after <ids…>] [--after-outcome <ids…>]\n' +
301
- ' code-forge block attempt|rebase <id> --run <r> · block close <id> --run <r> [--transcript <file>] [--report <file>] [--no-require-reviews] [--remove-worktree] · block claim <id> <path> --run <r> · block stop <id> --run <r> --reason <text>\n' +
532
+ ' code-forge block attempt|rebase <id> --run <r> · block close <id> --run <r> [--transcript <file>] [--report <file>] [--no-require-reviews] [--remove-worktree] [--auto-claim] · block claim <id> <path> --run <r> · block stop <id> --run <r> --reason <text>\n' +
302
533
  ' code-forge block waive <id> <finding-id> --run <r> --file <path> --reason <text> [--yes]\n' +
303
534
  ' code-forge block reopen <id> --run <r> --file <path> --reason <text> [--yes]\n' +
304
535
  ' code-forge block accept-hit <id> --run <r> [--rule <rule-id>] --line <n> --transcript <path> --reason <text>\n' +
@@ -306,6 +537,7 @@ const USAGE =
306
537
  ' code-forge block ack-waivers <id> --run <r>\n' +
307
538
  ' code-forge block human-pass <id> --run <r> --note <text> [--proof <dir-or-file>]\n' +
308
539
  ' code-forge block amend-acceptance <id> --run <r> --acceptance <file> --reason <text>\n' +
540
+ ' code-forge block land <id> --run <r> --report <file> --transcript <file> [--max <dur>] [--merge] [--no-merge-gate]\n' +
309
541
  ' every block verb takes --json: one JSON line on stdout ({"block","event",…}) instead of the text\n';
310
542
 
311
543
  /** @type {Record<string, string>} */
@@ -314,7 +546,8 @@ const VERB_USAGE = {
314
546
  attempt: '<id> --run <r>',
315
547
  rebase: '<id> --run <r> [--head <ref>] [--accept-changed <file>… --reason <text>]',
316
548
  claim: '<id> <path> --run <r>',
317
- close: '<id> --run <r> [--worker-pid <pid>] [--transcript <file>] [--report <file>] [--no-require-reviews] [--remove-worktree] [--decide]',
549
+ close: '<id> --run <r> [--worker-pid <pid>] [--transcript <file>] [--report <file>] [--no-require-reviews] [--remove-worktree] [--auto-claim] [--decide]',
550
+ land: '<id> --run <r> --report <file> --transcript <file> [--max <dur>] [--merge] [--no-merge-gate]',
318
551
  stop: '<id> --run <r> --reason <text>',
319
552
  waive: '<id> <finding-id> --run <r> --file <path> --reason <text> [--yes]',
320
553
  reopen: '<id> --run <r> --file <path> --reason <text> [--yes]',
@@ -334,7 +567,11 @@ function verbUsage(sub) {
334
567
  (sub === 'open' || sub === 'amend-acceptance'
335
568
  ? ' --acceptance <file> JSON/YAML list [{clause, tests}]; tests is a nonempty list of test ids.\n' +
336
569
  ' Human checks use {clause, kind: human_check} without tests.\n' : '') +
337
- (sub === 'open' ? ' --brief <file> A file inside the repository, not inline text.\n' : '');
570
+ (sub === 'open' ? ' --brief <file> A file inside the repository, not inline text.\n' : '') +
571
+ (sub === 'land' ? ' Join block reviews, run changed gates in its worktree, then close.\n' +
572
+ ' --max <dur> Total review wait (default 30m); accepts 90s, 30m or 1500ms.\n' +
573
+ ' --merge Check the merged full suite, then merge with --no-ff in the clean main checkout.\n' +
574
+ ' --no-merge-gate Skip the merged full suite with a warning.\n' : '');
338
575
  }
339
576
 
340
577
  /** Refuse likely shell quoting slips before any row is written.
@@ -361,7 +598,7 @@ function checkWhitespacePaths(workspace, entries, label) {
361
598
  }
362
599
  }
363
600
  }
364
- const FLAG_VALUES = ['run', 'level', 'acceptance', 'brief', 'attempt', 'base', 'lines', 'head', 'worker-pid', 'reason', 'file', 'transcript', 'kind', 'report', 'rule', 'line', 'note', 'proof'];
601
+ const FLAG_VALUES = ['run', 'level', 'acceptance', 'brief', 'attempt', 'base', 'lines', 'head', 'worker-pid', 'reason', 'file', 'transcript', 'kind', 'report', 'rule', 'line', 'note', 'proof', 'max'];
365
602
 
366
603
  /** Check specialist outcomes against the run's project, before state open can write anything.
367
604
  * @param {string} root @param {string} id @param {string[]} ids
@@ -423,7 +660,7 @@ export async function runBlock(args, deps = {}) {
423
660
  }
424
661
  let jsonOpenId;
425
662
  try {
426
- const { flags, positionals } = parseFlags(rest, { values: FLAG_VALUES, multi: ['owned', 'tests', ...(sub === 'rebase' ? ['accept-changed'] : []), ...(sub === 'open' ? ['after', 'after-outcome'] : [])], booleans: ['no-require-reviews', 'remove-worktree', 'json', ...(sub === 'close' ? ['decide'] : []), ...(['waive', 'reopen'].includes(sub) ? ['yes'] : [])] });
663
+ const { flags, positionals } = parseFlags(rest, { values: FLAG_VALUES, multi: ['owned', 'tests', ...(sub === 'rebase' ? ['accept-changed'] : []), ...(sub === 'open' ? ['after', 'after-outcome'] : [])], booleans: ['no-require-reviews', 'remove-worktree', 'json', ...(sub === 'land' ? ['merge', 'no-merge-gate'] : []), ...(sub === 'close' ? ['decide', 'auto-claim'] : []), ...(['waive', 'reopen'].includes(sub) ? ['yes'] : [])] });
427
664
  // B64a: --json prints ONE JSON line on success instead of the text lines (`emit`, one object
428
665
  // per verb, built where the text is); without it every output is exactly as before (`say` is `out`)
429
666
  const json = flags.json === true;
@@ -441,6 +678,19 @@ export async function runBlock(args, deps = {}) {
441
678
  if (sub === 'open' && json) jsonOpenId = id;
442
679
  const runId = typeof flags.run === 'string' ? flags.run : undefined;
443
680
  if (!id || !runId) throw new StateError('usage', 'a block id and --run are required');
681
+ if (sub === 'land' && (positionals.length !== 1 || typeof flags.report !== 'string' || typeof flags.transcript !== 'string' || Object.keys(flags).some((key) => !['run', 'report', 'transcript', 'max', 'merge', 'no-merge-gate', 'json'].includes(key)))) {
682
+ throw new StateError('usage', 'block land takes one block id, --run, --report, --transcript and optional --max/--merge/--no-merge-gate/--json');
683
+ }
684
+ let landMaxMs = 30 * 60_000;
685
+ if (sub === 'land' && flags.max !== undefined) {
686
+ // Reuse review-file's parser, with minutes as a convenience for long lands.
687
+ try {
688
+ landMaxMs = typeof flags.max === 'string' && /^\d{1,7}m$/.test(flags.max)
689
+ ? parseMax(`${Number(flags.max.slice(0, -1)) * 60}s`) : parseMax(flags.max);
690
+ } catch {
691
+ throw new StateError('usage', '--max must look like 90s, 30m or 1500ms');
692
+ }
693
+ }
444
694
  if (sub === 'amend-acceptance' && (positionals.length !== 1 || typeof flags.acceptance !== 'string' || typeof flags.reason !== 'string' || !flags.reason.trim() || /\p{Cc}/u.test(flags.reason))) {
445
695
  throw new StateError('usage', 'amend-acceptance needs one block id, --acceptance and a nonblank --reason without control characters');
446
696
  }
@@ -469,6 +719,7 @@ export async function runBlock(args, deps = {}) {
469
719
  return 0;
470
720
  }
471
721
  const record = await readRun(runId);
722
+ if (sub === 'land') return await landBlock({ id, runId, record, report: /** @type {string} */ (flags.report), transcript: /** @type {string} */ (flags.transcript), maxMs: landMaxMs, merge: flags.merge === true, noMergeGate: flags['no-merge-gate'] === true, json }, deps);
472
723
  if (sub === 'amend-acceptance') {
473
724
  /** @type {ReturnType<typeof prepareAcceptanceAmendment> | null} */
474
725
  let prepared = null;
@@ -665,7 +916,7 @@ export async function runBlock(args, deps = {}) {
665
916
  }
666
917
  afterOutcome = acceptedOutcomes(record.workspace, id, flags['after-outcome']);
667
918
  }
668
- const { block, pointer, worktreeReused } = await openBlock({
919
+ const { block, pointer, worktreeReused, keptClaims } = await openBlock({
669
920
  runId,
670
921
  id,
671
922
  level,
@@ -690,6 +941,7 @@ export async function runBlock(args, deps = {}) {
690
941
  const testCount = Array.isArray(block.test_files) ? block.test_files.length : 0;
691
942
  const tests = testCount > 0 ? ` · ${testCount} test file${testCount === 1 ? '' : 's'}` : '';
692
943
  say(`block ${id} open · ${block.level}${raised}${chosen} · ${block.kind} · attempt ${block.attempt} · base ${block.base_sha.slice(0, 12)}${tests}\n`);
944
+ if (keptClaims > 0) say(`kept claims: ${keptClaims}\n`);
693
945
  if (block.worktree) say(`worktree ${block.worktree.path} · branch ${block.worktree.branch}${worktreeReused ? ' · reused' : ''}\n`);
694
946
  say(`${pointer ?? 'brief: none given (--brief <file>)'}\n`);
695
947
  if (json) {
@@ -701,6 +953,7 @@ export async function runBlock(args, deps = {}) {
701
953
  source: typeof flags.level === 'string' ? 'flag' : rules ? 'rules' : 'autopilot',
702
954
  kind: block.kind,
703
955
  attempt: block.attempt,
956
+ ...(keptClaims > 0 ? { kept_claims: keptClaims } : {}),
704
957
  base_sha: block.base_sha,
705
958
  tests: testCount,
706
959
  worktree: block.worktree ? { path: block.worktree.path, branch: block.worktree.branch, reused: Boolean(worktreeReused) } : null,
@@ -710,9 +963,10 @@ export async function runBlock(args, deps = {}) {
710
963
  return 0;
711
964
  }
712
965
  if (sub === 'attempt') {
713
- const n = await attemptBlock({ runId, id, writeRow });
966
+ const { attempt: n, keptClaims } = await attemptBlock({ runId, id, writeRow });
714
967
  say(`block ${id} attempt ${n}\n`);
715
- if (json) emit({ block: id, event: 'attempt', attempt: n });
968
+ if (keptClaims > 0) say(`kept claims: ${keptClaims}\n`);
969
+ if (json) emit({ block: id, event: 'attempt', attempt: n, ...(keptClaims > 0 ? { kept_claims: keptClaims } : {}) });
716
970
  return 0;
717
971
  }
718
972
  if (sub === 'rebase') {
@@ -802,9 +1056,28 @@ export async function runBlock(args, deps = {}) {
802
1056
  }
803
1057
  // B58: the owner's other way out — `review.external.required` in the run's config snapshot
804
1058
  // (set before `run start`, immutable): no TTY, allowed under autopilot, the human flag wins
1059
+ if (flags['auto-claim'] === true) await autoClaimChanges({ record, runId, id, writeRow, print: json ? err : say });
805
1060
  const external = reviewsWaived ? null : externalReviewer(record);
806
1061
  const reviewConfig = configInForce(record);
807
- const rows = (await readAllRows(record.project)).filter((row) => row.run === runId);
1062
+ let rows = (await readAllRows(record.project)).filter((row) => row.run === runId);
1063
+ // Restart changed stopped findings before entering closeBlock's run lock: the worker
1064
+ // needs that lock to publish its fresh review. Refresh rows after the join finishes.
1065
+ if (!reviewsWaived && external === null && !deciding && routineGrant === null) {
1066
+ const mainTop = await repoTop(record.workspace);
1067
+ const restarted = await requeueStoppedFiles({ repoRoot: mainTop, run: runId, block: id,
1068
+ tree: wt?.top ?? mainTop, files: reviewedFiles(rows, id), print: err });
1069
+ if (restarted.size > 0) {
1070
+ let output = '';
1071
+ const code = await runReviewFile(['--wait-block', id, '--run', runId], {
1072
+ cwd: wt?.workspace ?? record.workspace, stdout: { write: (text) => { output += text; } }, stderr: { write: err },
1073
+ });
1074
+ if (code !== 0 || JSON.parse(output).done !== true) {
1075
+ err(`block ${id} open: automatic requeue did not finish\n`);
1076
+ return code || 1;
1077
+ }
1078
+ rows = (await readAllRows(record.project)).filter((row) => row.run === runId);
1079
+ }
1080
+ }
808
1081
  // B52: ONE path base and ONE listing, memoised as a promise and shared by every check. The
809
1082
  // base is the repository top level: the worker signs every review row with a repo-root-
810
1083
  // relative `file` and keeps its queue and signed result files under `<top>/.code-forge/`
@@ -10,12 +10,16 @@
10
10
  import { randomBytes } from 'node:crypto';
11
11
  import { rmSync } from 'node:fs';
12
12
  import path from 'node:path';
13
- import { loadProjectConfig } from '../config/load.mjs';
13
+ import { loadProjectConfig, projectRootFor } from '../config/load.mjs';
14
14
  import { runDoctor } from '../doctor/index.mjs';
15
15
  import { counts, exitCode, renderJSON, renderText } from '../doctor/rows.mjs';
16
16
  import { parseFlags } from '../state/cli-args.mjs';
17
+ import { configInForce } from '../state/config-snapshot.mjs';
18
+ import { readRun } from '../state/run.mjs';
19
+ import { daemonEnabled, daemonRequest, nativePath, runNative } from '../util/native.mjs';
17
20
  import { writeSafe } from '../util/redact.mjs';
18
21
  import { runRoot, setRunRoot } from '../util/tmp.mjs';
22
+ import { liveWorker } from '../worker/queue.mjs';
19
23
 
20
24
  const USAGE = 'usage: code-forge doctor [--quick] [--json] [--cwd <dir>]\n';
21
25
 
@@ -56,6 +60,33 @@ export async function runDoctorCli(args, deps = {}, adopt = false) {
56
60
  }
57
61
  try {
58
62
  const rows = await runDoctor({ cwd, quick: flags.quick === true, ...(root ? { runRootDir: root } : {}) }, deps);
63
+ if (!flags.quick) {
64
+ const binary = nativePath();
65
+ let nativeDetail = 'not found, using Node';
66
+ let nativeOK = false;
67
+ if (binary) {
68
+ const result = await runNative(['version']);
69
+ try {
70
+ const version = result?.code === 0 ? JSON.parse(result.stdout) : null;
71
+ if (typeof version?.version === 'string') {
72
+ nativeOK = true;
73
+ nativeDetail = `${binary}, version ${version.version}`;
74
+ } else nativeDetail = `${binary}: unavailable, using Node`;
75
+ } catch { nativeDetail = `${binary}: unavailable, using Node`; }
76
+ }
77
+ rows.push({ id: 'native', status: nativeOK ? 'OK' : 'INFO', label: 'native binary', detail: nativeDetail });
78
+ let daemon = 'off';
79
+ try {
80
+ const loaded = await loadProjectConfig(cwd);
81
+ const worker = liveWorker(projectRootFor(cwd));
82
+ const config = (worker ? configInForce(await readRun(worker.run)) : null)
83
+ ?? (loaded.ok ? loaded.config : { native: { daemon: false } });
84
+ if (daemonEnabled(config)) {
85
+ daemon = worker && await daemonRequest(worker.run, { method: 'ping' }, { config }) ? 'live' : 'fallback';
86
+ }
87
+ } catch { /* an unreadable config or worker leaves native acceleration off */ }
88
+ rows.push({ id: 'native_daemon', status: 'INFO', label: 'native daemon', detail: daemon });
89
+ }
59
90
  writeSafe(stdout, flags.json ? renderJSON(rows) : renderText(rows));
60
91
  const c = counts(rows);
61
92
  writeSafe(stderr, `doctor: ${c.OK} ok, ${c.WARN} warn, ${c.FAIL} fail\n`);
package/src/cli/help.mjs CHANGED
@@ -47,6 +47,7 @@ export default async function help(_args, context = {}) {
47
47
  process.stdout.write('\nBlock usage: code-forge block <verb> --help (or -h).\n');
48
48
  process.stdout.write(' block open --acceptance <file>: JSON/YAML list [{clause, tests}]; tests is a nonempty list of test ids.\n');
49
49
  process.stdout.write(' block open --brief <file>: a file inside the repository, not inline text.\n');
50
+ process.stdout.write(' block land <id> --run <r> --report <file> --transcript <file> [--merge] [--json]: join reviews, run changed gates, close, then optionally merge with --no-ff.\n');
50
51
  process.stdout.write(' Configuration fallback entries must be {provider, model} objects, not model-name strings (optional effort).\n');
51
52
  return 0;
52
53
  }
@@ -0,0 +1,13 @@
1
+ // One companion per native daemon: reuse status's config and review-gate rules
2
+ // over the daemon's cached record/rows, without rereading the ledger or running ps.
3
+ import { createInterface } from 'node:readline';
4
+ import { statusOf } from './status.mjs';
5
+
6
+ for await (const line of createInterface({ input: process.stdin, crlfDelay: Infinity })) {
7
+ try {
8
+ const { record, rows } = JSON.parse(line);
9
+ process.stdout.write(`${JSON.stringify({ runs: [await statusOf(record, rows)] })}\n`);
10
+ } catch {
11
+ process.stdout.write(`${JSON.stringify({ ok: false, error: 'status-unreadable' })}\n`);
12
+ }
13
+ }