@codedology/code-forge 0.14.1 → 0.15.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.
@@ -12,7 +12,7 @@
12
12
  */
13
13
 
14
14
  import { createHash, randomUUID } from 'node:crypto';
15
- import { createReadStream, existsSync, lstatSync, readlinkSync, realpathSync } from 'node:fs';
15
+ import { createReadStream, existsSync, lstatSync, readFileSync, readdirSync, readlinkSync, realpathSync } from 'node:fs';
16
16
  import os from 'node:os';
17
17
  import path from 'node:path';
18
18
  import { BLOCK_KINDS, blockKind, dispatchLevel } from '../decide/escalation.mjs';
@@ -25,6 +25,8 @@ import { StateError } from './paths.mjs';
25
25
  import { assertOwned, blocksOverlap, isExactEntry, ownsFile, scopeGate } from './registry.mjs';
26
26
  import { checkWorkerPin, readRun, saveRun, withRunLock, writeSigned } from './run.mjs';
27
27
  import { verifyRow, loadKey } from './signer.mjs';
28
+ import * as reviewQueue from '../worker/queue.mjs';
29
+ import { repoRootOf } from '../worker/ticket.mjs';
28
30
 
29
31
  /** @typedef {import('./run.mjs').WriteRow} WriteRow */
30
32
 
@@ -216,16 +218,18 @@ export function briefPointer(relPath, content) {
216
218
 
217
219
  /**
218
220
  * @param {unknown} clauses
219
- * @returns {{clause: string, tests: string[]}[]}
221
+ * @returns {import('../gates/acceptance.mjs').AcceptanceClause[]}
220
222
  */
221
223
  function assertAcceptance(clauses) {
222
224
  const ok =
223
225
  Array.isArray(clauses) &&
224
226
  clauses.length > 0 &&
225
227
  clauses.every(
226
- (c) => typeof c?.clause === 'string' && c.clause.length > 0 && Array.isArray(c.tests) && c.tests.length > 0 && c.tests.every((t) => typeof t === 'string' && t.length > 0),
228
+ (c) => typeof c?.clause === 'string' && c.clause.length > 0 && (c.kind === 'human_check'
229
+ ? !Object.hasOwn(c, 'tests')
230
+ : Array.isArray(c.tests) && c.tests.length > 0 && c.tests.every((t) => typeof t === 'string' && t.length > 0)),
227
231
  );
228
- if (!ok) throw new StateError('bad-acceptance', 'acceptance: a non-empty list of {clause, tests: [test ids…]} is required');
232
+ if (!ok) throw new StateError('bad-acceptance', 'acceptance: a non-empty list of {clause, tests: [test ids…]} or {clause, kind: human_check} without tests is required');
229
233
  return clauses;
230
234
  }
231
235
 
@@ -315,6 +319,7 @@ function refuseConfigOwner(owned, kinds, guarded) {
315
319
  * and the dispatch row carry the floor level, and the row adds `requested_level` and
316
320
  * `trigger: 'docs_floor'`. B52: the entry also records `tree_at_open` ({@link treeAtOpen}), the
317
321
  * tree's changes at open, which `block close` reads to tell the coder's changes from earlier ones.
322
+ * B134: `base_branch` is the workspace's current branch, or null when detached.
318
323
  * B58: with `review.external.required` set, an owned entry that owns the project's config is
319
324
  * refused (`config-owned`, {@link refuseConfigOwner}).
320
325
  * B59: `decision` (the rules lane of a brief-mode block) is written as a signed `decision` row
@@ -352,12 +357,13 @@ export async function openBlock(opts) {
352
357
  if (typeof level !== 'string' || !LEVEL.test(level)) throw new StateError('bad-level', 'level must be L0..L3');
353
358
  if (!Number.isInteger(attempt) || attempt < 1) throw new StateError('bad-attempt', 'attempt must be an integer >= 1');
354
359
  if (lines !== null && !(Number.isInteger(lines) && lines > 0)) throw new StateError('bad-lines', 'lines forecast must be a positive integer');
355
- if (opts.kind !== undefined && !BLOCK_KINDS.includes(opts.kind)) throw new StateError('bad-kind', `kind must be one of ${BLOCK_KINDS.join(', ')}`);
360
+ if (opts.kind !== undefined && opts.kind !== 'light' && !BLOCK_KINDS.includes(opts.kind)) throw new StateError('bad-kind', `kind must be one of ${[...BLOCK_KINDS, 'light'].join(', ')}`);
356
361
  if (opts.decision && opts.decision.answer !== level) throw new StateError('bad-decision', `the decision answer ${opts.decision.answer} is not the requested level ${level}`);
357
362
  const owned = assertOwned(opts.owned);
358
363
  const acceptance = assertAcceptance(opts.acceptance);
364
+ const humanChecks = acceptance.filter((c) => c.kind === 'human_check').map((c) => c.clause);
359
365
  const tests = opts.tests === undefined ? null : [...assertOwned(opts.tests, { label: 'test files', code: 'bad-tests' })];
360
- const kind = blockKind({ owned, declared: opts.kind });
366
+ const kind = opts.kind === 'light' ? 'light' : blockKind({ owned, declared: opts.kind });
361
367
  const dispatched = dispatchLevel({ lane: level, kind, cfg: opts.cfg });
362
368
  const pointer = opts.brief ? briefPointer(opts.brief.path, opts.brief.content) : null;
363
369
 
@@ -384,6 +390,7 @@ export async function openBlock(opts) {
384
390
  try {
385
391
  const { block, reused } = await mutateRun(runId, null, writeRow, async (record) => {
386
392
  if (record.blocks[id]?.status === 'open') throw new StateError('already-open', `block ${id} is already open`);
393
+ if (record.blocks[id]?.status === 'awaiting_human_check') throw new StateError('already-open', `block ${id} is awaiting a human check`);
387
394
  // B57: what each exact owned entry is on disk, under the lock (as `block claim`) — a regular
388
395
  // file owns only itself, here and in every later check
389
396
  const kinds = ownedKinds(record.workspace, owned);
@@ -391,6 +398,8 @@ export async function openBlock(opts) {
391
398
  const prior = previousTree(record);
392
399
  const baseSha = await resolveCommit(prior ? prior.base_sha : opts.base ?? 'HEAD', record.workspace);
393
400
  if (baseSha !== stampedBase || record.workspace !== workspace) throw new StateError('base-moved', `the base moved while block ${id} was being opened; run block open again`);
401
+ const branch = await git(['git', 'symbolic-ref', '--quiet', '--short', 'HEAD'], record.workspace, [0, 1]);
402
+ const baseBranch = branch.code === 0 ? branch.stdout.trim() : null;
394
403
  // B62a: the tree is planned (read-only checks) before the overlap check, which needs its path
395
404
  const planned = opts.worktree ? await opts.worktree({ baseSha, previous: prior }) : null;
396
405
  const self = { owned_files: owned, owned_kinds: kinds, worktree: planned ? { path: planned.path } : null };
@@ -402,7 +411,8 @@ export async function openBlock(opts) {
402
411
  // passed; a tree made here is rolled back below if the rows or the save then fail
403
412
  made = planned ? await planned.make() : null;
404
413
  const tree = made;
405
- const entry = { block: id, base_sha: baseSha, owned_files: [...owned], owned_kinds: { ...kinds }, level: dispatched.level, kind, attempt, opened_at: now.toISOString(), acceptance, lines_forecast: lines, status: 'open', tree_at_open: treeOpen, ...(tests ? { test_files: tests } : {}), ...(tree ? { worktree: tree.worktree } : {}) };
414
+ const entry = { block: id, base_sha: baseSha, base_branch: baseBranch, owned_files: [...owned], owned_kinds: { ...kinds }, level: dispatched.level, kind, attempt, opened_at: now.toISOString(), acceptance, lines_forecast: lines, status: 'open', tree_at_open: treeOpen, ...(tests ? { test_files: tests } : {}), ...(tree ? { worktree: tree.worktree } : {}) };
415
+ if (humanChecks.length > 0) Object.assign(entry, { human_checks: humanChecks });
406
416
  record.blocks[id] = entry;
407
417
  const floor = dispatched.trigger === 'docs_floor' ? { requested_level: level, trigger: 'docs_floor' } : {};
408
418
  const created = tree?.created ? [{ event: 'worktree.created', block: id, path: tree.worktree.path, branch: tree.worktree.branch, base: tree.worktree.base_sha }] : [];
@@ -411,7 +421,7 @@ export async function openBlock(opts) {
411
421
  rows: [
412
422
  ...(opts.decision ? [{ ...opts.decision, event: 'decision', decision_id: opts.decisionId ?? randomUUID(), block: id }] : []),
413
423
  ...created,
414
- { event: 'dispatch', block: id, level: dispatched.level, kind, ...floor, attempt, base_sha: baseSha, owned_files: entry.owned_files, lines_forecast: lines, ...(tests ? { test_files: tests } : {}) },
424
+ { event: 'dispatch', block: id, level: dispatched.level, kind, ...floor, attempt, base_sha: baseSha, base_branch: baseBranch, owned_files: entry.owned_files, lines_forecast: lines, ...(tests ? { test_files: tests } : {}) },
415
425
  ],
416
426
  };
417
427
  });
@@ -444,11 +454,14 @@ export async function attemptBlock({ runId, id, writeRow }) {
444
454
  * in between touched a file this block owns (that is a conflict ⇒ stop). Rename detection is OFF
445
455
  * (`--no-renames`): a rename lists both its old and its new path, so moving an owned file away
446
456
  * is a conflict too. Never silent. Owned means `ownsFile` (B57: a directory entry owns the files
447
- * below it). B62a: refused for a block with its own worktree (`worktree-rebase`).
457
+ * below it). B62a: refused for an open block with its own worktree (`worktree-rebase`).
458
+ * B134: a closed worktree block instead rebases its branch onto its recorded base branch,
459
+ * retaining every changed owned file's blob; a conflict or changed blob restores the old head.
448
460
  * @param {{runId: string, id: string, head?: string, writeRow: WriteRow}} opts
449
461
  * @returns {Promise<{old_base: string, new_base: string}>}
450
462
  */
451
463
  export async function rebaseBlock({ runId, id, head = 'HEAD', writeRow }) {
464
+ if (['closed', 'awaiting_human_check'].includes((await readRun(runId)).blocks[id]?.status)) return rebaseClosedBlock({ runId, id, writeRow });
452
465
  return mutateRun(runId, id, writeRow, async (record, block) => {
453
466
  // B62a: a worktree block's base is the commit its tree started from; moving it would leave the
454
467
  // tree on its old base, so the rebase is refused
@@ -466,6 +479,75 @@ export async function rebaseBlock({ runId, id, head = 'HEAD', writeRow }) {
466
479
  });
467
480
  }
468
481
 
482
+ /**
483
+ * Hash committed paths, with null for a deletion. NUL-delimited tree entries preserve unusual
484
+ * file names; filter exact names because git's path selection may also return descendants.
485
+ * @param {string} cwd @param {string} ref @param {string[]} files
486
+ * @returns {Promise<Map<string, string | null>>}
487
+ */
488
+ async function committedBlobs(cwd, ref, files) {
489
+ const hashes = new Map(files.map((file) => [file, /** @type {string | null} */ (null)]));
490
+ if (files.length === 0) return hashes;
491
+ const tree = await git(['git', 'ls-tree', '-r', '-z', '--full-tree', ref, '--', ...files], cwd);
492
+ for (const entry of tree.stdout.split('\0').filter(Boolean)) {
493
+ const tab = entry.indexOf('\t');
494
+ const file = entry.slice(tab + 1);
495
+ if (hashes.has(file)) hashes.set(file, entry.slice(0, tab).split(' ')[2]);
496
+ }
497
+ return hashes;
498
+ }
499
+
500
+ /**
501
+ * Closed blocks have a separate mutation path: check their status again under the lock,
502
+ * restore the branch on failure (including a failed signed write), and retain closed/awaiting status.
503
+ * @param {{runId: string, id: string, writeRow: WriteRow}} opts
504
+ * @returns {Promise<{old_base: string, new_base: string}>}
505
+ */
506
+ async function rebaseClosedBlock({ runId, id, writeRow }) {
507
+ return withRunLock(runId, async () => {
508
+ const record = await readRun(runId);
509
+ if (record.status !== 'active') throw new StateError('run-ended', `run ${runId} has ended`);
510
+ const block = record.blocks[id];
511
+ if (!['closed', 'awaiting_human_check'].includes(block?.status) || !block.worktree) throw new StateError('no-block', `block ${id} is not open in run ${runId}`);
512
+ if (!block.base_branch) throw new StateError('rebase-no-base', `block ${id} has no recorded base branch`);
513
+ const tree = block.worktree.path;
514
+ const branch = await git(['git', 'symbolic-ref', '--quiet', 'HEAD'], tree, [0, 1]);
515
+ if (branch.stdout.trim() !== `refs/heads/${block.worktree.branch}`) throw new StateError('worktree-rebase', `block ${id}: its worktree is not on branch ${block.worktree.branch}`);
516
+ // A reset must never discard edits made after close, or abort an earlier rebase.
517
+ const status = await git(['git', 'status', '--porcelain'], tree);
518
+ if (status.stdout.length > 0) throw new StateError('rebase-conflict', 'rebase-conflict');
519
+ const oldHead = await resolveCommit('HEAD', tree);
520
+ const newBase = await resolveCommit(`refs/heads/${block.base_branch}`, record.workspace);
521
+ const top = await repoTop(record.workspace);
522
+ const prefix = path.relative(top, realpathSync(record.workspace)).split(path.sep).join('/');
523
+ const diff = await git(['git', 'diff', '--no-renames', '--name-only', '-z', block.base_sha, oldHead, '--'], tree);
524
+ const files = diff.stdout.split('\0').filter((file) => file.length > 0 && (
525
+ ownsFile(block.owned_files, file, block.owned_kinds) ||
526
+ (prefix !== '' && file.startsWith(`${prefix}/`) && ownsFile(block.owned_files, file.slice(prefix.length + 1), block.owned_kinds))
527
+ )).sort();
528
+ const before = await committedBlobs(tree, oldHead, files);
529
+ const moved = { old_base: block.base_sha, new_base: newBase };
530
+ try {
531
+ const rebased = await exec(['git', '-C', tree, 'rebase', newBase], { cwd: record.workspace, env: gitEnv(), timeoutMs: 30000 });
532
+ if (rebased.result !== 'ok') throw new StateError('rebase-conflict', 'rebase-conflict');
533
+ const after = await committedBlobs(tree, 'HEAD', files);
534
+ const changed = files.filter((file) => before.get(file) !== after.get(file));
535
+ if (changed.length > 0) throw new StateError('rebase-changed-reviewed', `rebase-changed-reviewed ${changed.join(', ')}`);
536
+ block.base_sha = newBase;
537
+ block.worktree.base_sha = newBase;
538
+ await writeSigned(runId, writeRow, { event: 'block.rebase', block: id, ...moved, closed: true });
539
+ await saveRun(record);
540
+ return moved;
541
+ } catch (err) {
542
+ // --abort restores a conflicted rebase; after a completed rebase there is nothing to
543
+ // abort, and reset restores the old branch head. The tree was clean before either.
544
+ await exec(['git', '-C', tree, 'rebase', '--abort'], { cwd: record.workspace, env: gitEnv(), timeoutMs: 30000 });
545
+ await git(['git', 'reset', '--hard', oldHead], tree);
546
+ throw err;
547
+ }
548
+ });
549
+ }
550
+
469
551
  /**
470
552
  * `block claim` — assign one EXACT path (an orphan, or any unowned path) to `id`; refused for a
471
553
  * glob, and when another open block owns it — or anything it would own: the claimed path becomes an
@@ -513,13 +595,35 @@ export async function checkScope({ runId, id, files, writeRow }) {
513
595
 
514
596
  /** @param {{runId: string, id: string, reason: string, writeRow: WriteRow, event?: string}} opts */
515
597
  export async function stopBlock({ runId, id, reason, writeRow, event = 'block.stop' }) {
516
- await mutateRun(runId, id, writeRow, (_record, block) => {
598
+ await mutateRun(runId, null, writeRow, (record) => {
599
+ const block = record.blocks[id];
600
+ if (!['open', 'awaiting_human_check'].includes(block?.status)) throw new StateError('no-block', `block ${id} is not open in run ${runId}`);
517
601
  block.status = 'stopped';
518
602
  block.stop_reason = reason;
519
603
  return { result: undefined, rows: [{ event, block: id, reason }] };
520
604
  });
521
605
  }
522
606
 
607
+ /**
608
+ * Called only after the CLI's terminal confirmation; recheck under the lock before signing.
609
+ * @param {{runId: string, id: string, note: string, proof?: string | null, writeRow: WriteRow, now?: Date}} opts
610
+ */
611
+ export async function humanPassBlock({ runId, id, note, proof = null, writeRow, now = new Date() }) {
612
+ return mutateRun(runId, null, writeRow, (record) => {
613
+ const block = record.blocks[id];
614
+ if (block?.status !== 'awaiting_human_check') throw new StateError('not-awaiting', `block ${id} is not awaiting a human check`);
615
+ if (typeof note !== 'string' || note.trim().length === 0) throw new StateError('usage', 'block human-pass needs --note <text>');
616
+ if (proof !== null && (typeof proof !== 'string' || !existsSync(proof))) throw new StateError('usage', '--proof must name an existing directory or file');
617
+ const grant = record.autopilot;
618
+ if (grant && !['stopped', 'expired'].includes(grant.status) && !(now.getTime() >= Date.parse(grant.until))) {
619
+ throw new StateError('autopilot-active', `block human-pass is refused while an autopilot grant is active on run ${runId}`);
620
+ }
621
+ const row = { event: 'block.human_pass', block: id, note, proof, checks: [...block.human_checks], by: 'human' };
622
+ block.status = 'closed';
623
+ return { result: row, rows: [row] };
624
+ });
625
+ }
626
+
523
627
  /**
524
628
  * B8's rows of the block gate, in order: worker pin ⇒ `worker.replaced`/`worker.down` (block
525
629
  * stopped); the block's signed `dispatch` row must exist and verify, and every other
@@ -533,16 +637,44 @@ export async function stopBlock({ runId, id, reason, writeRow, event = 'block.st
533
637
  * livePid?: number, probe?: import('./run.mjs').StartTimeProbe,
534
638
  * extraChecks?: Array<() => Promise<{ok: boolean, reason?: string}>>,
535
639
  * closeRows?: Record<string, any>[],
640
+ * dryRun?: boolean,
536
641
  * }} opts
537
- * @returns {Promise<{ok: boolean, status: 'closed' | 'stopped' | 'open', reason?: string, event?: string}>}
642
+ * @returns {Promise<{ok: boolean, status: 'closed' | 'awaiting_human_check' | 'stopped' | 'open', reason?: string, event?: string}>}
538
643
  */
539
- export async function closeBlock({ runId, id, rows, writeRow, livePid, probe, extraChecks = [], closeRows = [] }) {
644
+ export async function closeBlock({ runId, id, rows, writeRow, livePid, probe, extraChecks = [], closeRows = [], dryRun = false }) {
540
645
  const record = await readRun(runId);
541
646
  if (record.blocks[id]?.status !== 'open') throw new StateError('no-block', `block ${id} is not open in run ${runId}`);
542
647
 
543
- const pin = await checkWorkerPin(record, { livePid, probe });
648
+ let pin = await checkWorkerPin(record, { livePid, probe });
649
+ const pins = record.workers ?? (record.worker ? [record.worker] : []);
650
+ const counted = rows.filter((row) => row.block === id && (row.run === undefined || row.run === runId) && row.event === 'review.result' && !['stale', 'unavailable', 'answers'].includes(row.status) && !(row.status === 'refused' && row.reason === 'other-run'));
651
+ // Ledgers written before B107 omit worker identity and use the run's pin check above.
652
+ if (counted.some((row) => row.worker !== undefined && !pins.some((p) => p.pid === row.worker?.pid && p.started_at === row.worker?.started_at))) {
653
+ pin = { ok: false, event: 'worker.replaced', reason: 'a counted review result does not name a pinned worker' };
654
+ }
655
+ if (pin.ok) {
656
+ const repoRoot = await repoRootOf(record.workspace);
657
+ let names;
658
+ try { names = readdirSync(reviewQueue.queueDir(repoRoot)); }
659
+ catch (err) { if (err.code !== 'ENOENT') throw err; names = []; }
660
+ for (const name of names.filter((n) => /^[0-9a-f]{24}\.json$/.test(n))) {
661
+ const ticket = name.slice(0, 24);
662
+ const queued = reviewQueue.readTicket(repoRoot, ticket);
663
+ if (queued.run !== runId || queued.block !== id) continue;
664
+ const claimKey = `ticket\0${runId}\0${ticket}`;
665
+ const file = path.join(os.homedir(), '.code-forge', 'locks', `${createHash('sha256').update(claimKey).digest('hex')}.lock`);
666
+ let claim;
667
+ try { claim = JSON.parse(readFileSync(file, 'utf8')); }
668
+ catch (err) { if (err.code === 'ENOENT') continue; throw err; }
669
+ const owner = pins.find((p) => p.pid === claim.pid && p.started_at === claim.start);
670
+ if (owner && !(await checkWorkerPin({ worker: owner }, { probe })).ok) {
671
+ pin = { ok: false, event: 'worker.down', reason: `dead pinned worker pid ${owner.pid} holds ticket ${ticket}` };
672
+ break;
673
+ }
674
+ }
675
+ }
544
676
  if (!pin.ok) {
545
- await stopBlock({ runId, id, reason: pin.reason, writeRow, event: pin.event });
677
+ if (!dryRun) await stopBlock({ runId, id, reason: pin.reason, writeRow, event: pin.event });
546
678
  return { ok: false, status: 'stopped', event: pin.event, reason: pin.reason };
547
679
  }
548
680
 
@@ -555,25 +687,35 @@ export async function closeBlock({ runId, id, rows, writeRow, livePid, probe, ex
555
687
  const reasons = bad.map((x) => `${x.row.event}: ${x.result.reason}`);
556
688
  if (!dispatchOk) reasons.push('no verified dispatch row');
557
689
  const reason = reasons.join('; ');
558
- await stopBlock({ runId, id, reason, writeRow, event: 'ledger.tamper' });
690
+ if (!dryRun) await stopBlock({ runId, id, reason, writeRow, event: 'ledger.tamper' });
559
691
  return { ok: false, status: 'stopped', event: 'ledger.tamper', reason };
560
692
  }
561
693
 
694
+ const failures = [];
562
695
  for (const check of extraChecks) {
563
696
  const result = await check();
564
- if (!result.ok) return { ok: false, status: 'open', reason: result.reason ?? 'check failed' };
697
+ if (!result.ok) {
698
+ if (!dryRun) return { ok: false, status: 'open', reason: result.reason ?? 'check failed' };
699
+ failures.push(result.reason ?? 'check failed');
700
+ }
701
+ }
702
+ if (dryRun) {
703
+ if (record.orphans.length > 0) failures.push(`orphans: ${record.orphans.join(', ')}`);
704
+ return { ok: failures.length === 0, status: 'open', ...(failures.length ? { reason: failures.join('; ') } : {}) };
565
705
  }
566
706
 
567
- /** @typedef {{ok: boolean, status: 'closed' | 'stopped' | 'open', reason?: string, event?: string}} CloseResult */
707
+ /** @typedef {{ok: boolean, status: 'closed' | 'awaiting_human_check' | 'stopped' | 'open', reason?: string, event?: string}} CloseResult */
568
708
  return mutateRun(runId, id, writeRow, (current, block) => {
569
709
  if (current.orphans.length > 0) {
570
710
  /** @type {CloseResult} */
571
711
  const refused = { ok: false, status: 'open', reason: `orphans: ${current.orphans.join(', ')}` };
572
712
  return { result: refused, rows: [] };
573
713
  }
574
- block.status = 'closed';
714
+ const checks = block.human_checks ?? [];
715
+ block.status = checks.length > 0 ? 'awaiting_human_check' : 'closed';
575
716
  /** @type {CloseResult} */
576
- const closed = { ok: true, status: 'closed' };
577
- return { result: closed, rows: [...closeRows, { event: 'block.close', block: id, status: 'complete' }] };
717
+ const closed = { ok: true, status: block.status };
718
+ return { result: closed, rows: [...closeRows, { event: 'block.close', block: id, status: 'complete' },
719
+ ...(checks.length > 0 ? [{ event: 'block.awaiting_human_check', block: id, checks }] : [])] };
578
720
  });
579
721
  }
package/src/state/run.mjs CHANGED
@@ -12,6 +12,7 @@
12
12
  * parameter; the CLI verb does the static import) and are SIGNED with the run key first.
13
13
  */
14
14
 
15
+ import { existsSync } from 'node:fs';
15
16
  import { mkdir, readFile, rename, rmdir, writeFile } from 'node:fs/promises';
16
17
  import path from 'node:path';
17
18
  import { exec } from '../util/exec.mjs';
@@ -31,7 +32,7 @@ const LOCK_POLL_MS = 25;
31
32
  */
32
33
  export async function processStartTime(pid) {
33
34
  if (!Number.isInteger(pid) || pid <= 0) return null;
34
- const res = await exec(['ps', '-o', 'lstart=', '-p', String(pid)], {
35
+ const res = await exec([existsSync('/bin/ps') ? '/bin/ps' : 'ps', '-o', 'lstart=', '-p', String(pid)], {
35
36
  env: { ...process.env, LC_ALL: 'C' },
36
37
  okExitCodes: [0, 1],
37
38
  timeoutMs: 10000,
@@ -177,6 +178,7 @@ export async function startRun(opts) {
177
178
  orchestrator_pid: opts.orchestratorPid ?? process.pid,
178
179
  solo_pid: opts.soloPid ?? null,
179
180
  worker,
181
+ workers: worker ? [worker] : [],
180
182
  status: 'active',
181
183
  blocks: {},
182
184
  orphans: [],
@@ -250,6 +252,10 @@ export async function reloadRun({ runId, config, readPending = () => [], effecti
250
252
  if (oldHash === newHash) return { changed: [], oldHash, newHash, pinned: 0, rowError: null };
251
253
  const before = state && Object.hasOwn(stored, state.hash) ? stored[state.hash] : undefined;
252
254
  if (state && (before === null || typeof before !== 'object')) throw new StateError('config-snapshot', `run ${runId}: the current config snapshot is missing from the run record`);
255
+ // B138: close authority belongs to the run-start snapshot, including legacy runs.
256
+ if (before?.close?.require_human !== config.close?.require_human) {
257
+ throw new StateError('immutable-key', 'close.require_human cannot change mid-run (fixed by the owner before the run starts)');
258
+ }
253
259
  /** @type {string[]} */
254
260
  let frozen;
255
261
  if (state) {
@@ -309,15 +315,18 @@ export async function reloadRun({ runId, config, readPending = () => [], effecti
309
315
 
310
316
  /**
311
317
  * `run start --reattach`: re-pin a restarted worker (the orchestrator only — §4.8).
312
- * @param {{runId: string, workerPid: number, writeRow: WriteRow, probe?: StartTimeProbe}} opts
318
+ * @param {{runId: string, workerPid: number, writeRow: WriteRow, probe?: StartTimeProbe, add?: boolean}} opts
313
319
  */
314
- export async function reattachWorker({ runId, workerPid, writeRow, probe = processStartTime }) {
320
+ export async function reattachWorker({ runId, workerPid, writeRow, probe = processStartTime, add = false }) {
315
321
  if (!isSignalablePid(workerPid)) throw new StateError('bad-worker-pid', 'worker pid must be an integer > 1');
316
322
  return withRunLock(runId, async () => {
317
323
  const current = await readRun(runId);
318
324
  if (current.status !== 'active') throw new StateError('run-ended', `run ${runId} has ended`);
319
- current.worker = await pinWorker(workerPid, probe);
320
- await writeSigned(runId, writeRow, { event: 'worker.reattach', pid: workerPid });
325
+ const pin = await pinWorker(workerPid, probe);
326
+ const pins = current.workers ?? (current.worker ? [current.worker] : []);
327
+ current.workers = add ? [...pins, pin] : [pin];
328
+ current.worker = current.workers[0];
329
+ await writeSigned(runId, writeRow, { event: add ? 'worker.add' : 'worker.reattach', pid: workerPid });
321
330
  await saveRun(current);
322
331
  return current;
323
332
  });
@@ -331,7 +340,18 @@ export async function reattachWorker({ runId, workerPid, writeRow, probe = proce
331
340
  * @returns {Promise<{ok: boolean, event?: 'worker.replaced' | 'worker.down', reason?: string}>}
332
341
  */
333
342
  export async function checkWorkerPin(record, { livePid, probe = processStartTime } = {}) {
334
- const pin = record.worker;
343
+ let pin = record.worker;
344
+ if (record.workers?.length > 1) {
345
+ const pins = record.workers;
346
+ if (pins.some((p) => !isSignalablePid(p.pid)) || (livePid !== undefined && !pins.some((p) => p.pid === livePid))) {
347
+ return { ok: false, event: 'worker.replaced', reason: 'the live worker does not match a pinned worker' };
348
+ }
349
+ if (livePid === undefined) {
350
+ const live = await Promise.all(pins.map(async (p) => (await probe(p.pid)) === p.started_at));
351
+ return live.some(Boolean) ? { ok: true } : { ok: false, event: 'worker.down', reason: 'no pinned worker is running' };
352
+ }
353
+ pin = pins.find((p) => p.pid === livePid);
354
+ }
335
355
  if (!pin) return { ok: false, event: 'worker.down', reason: 'no worker is pinned for this run' };
336
356
  if (!isSignalablePid(pin.pid)) return { ok: false, event: 'worker.replaced', reason: 'the pinned worker pid is not an integer > 1' };
337
357
  if (livePid !== undefined && livePid !== pin.pid) {
@@ -380,14 +400,21 @@ export async function endRun({ runId, writeRow, stopWorker = stopPinnedWorker, p
380
400
  return withRunLock(runId, async () => {
381
401
  const current = await readRun(runId);
382
402
  if (current.status !== 'active') throw new StateError('run-ended', `run ${runId} has already ended`);
403
+ const awaiting = Object.keys(current.blocks).filter((id) => current.blocks[id].status === 'awaiting_human_check');
404
+ if (awaiting.length > 0) throw new StateError('awaiting-human-check', `run ${runId} has blocks awaiting human check: ${awaiting.join(', ')}; human-pass or stop them first`);
383
405
  const open = Object.keys(current.blocks).filter((id) => current.blocks[id].status === 'open');
384
406
  if (open.length > 0) throw new StateError('open-blocks', `run ${runId} still has open blocks: ${open.join(', ')}`);
385
407
  const pin = await checkWorkerPin(current, { probe });
386
408
  if (!pin.ok) await writeSigned(runId, writeRow, { event: pin.event, reason: pin.reason });
387
- const stop = pin.ok ? await stopWorker(current.worker, { probe }) : { stopped: false };
409
+ let stopped = false;
410
+ for (const worker of current.workers ?? (current.worker ? [current.worker] : [])) {
411
+ if ((await checkWorkerPin({ worker }, { probe })).ok) {
412
+ stopped = (await stopWorker(worker, { probe })).stopped || stopped;
413
+ }
414
+ }
388
415
  current.status = 'ended';
389
416
  current.ended_at = new Date().toISOString();
390
- await writeSigned(runId, writeRow, { event: 'run.end', worker_check: pin.ok ? 'ok' : pin.event, worker_stopped: stop.stopped });
417
+ await writeSigned(runId, writeRow, { event: 'run.end', worker_check: pin.ok ? 'ok' : pin.event, worker_stopped: stopped });
391
418
  await saveRun(current);
392
419
  return current;
393
420
  });
@@ -282,6 +282,7 @@ export const FORBIDDEN = Object.freeze(
282
282
  * - `--no-require-reviews` — the human's way out of `block close`'s per-file review check;
283
283
  * - `--remove-worktree` (B62a) — `block close` removing the block's worktree, the orchestrator's
284
284
  * alone. `block close` itself is not on this list, so the flag is listed on its own.
285
+ * - `code-forge block human-pass` (B137) — passing a human device check is human-only.
285
286
  * @type {ReadonlyArray<ForbiddenEntry>}
286
287
  */
287
288
  export const CODER_ONLY_FORBIDDEN = Object.freeze(
@@ -346,6 +347,19 @@ export const CODER_ONLY_FORBIDDEN = Object.freeze(
346
347
  prefixes: [['code-forge', 'block', 'ack-waivers'], ['forge', 'block', 'ack-waivers']],
347
348
  description: 'Acknowledge agent waivers — human-only, confirmed in the human terminal (B124b)',
348
349
  },
350
+ {
351
+ id: 'code-forge-block-human-pass-from-coder',
352
+ kind: 'anyPrefix',
353
+ prefixes: [['code-forge', 'block', 'human-pass'], ['forge', 'block', 'human-pass']],
354
+ description: 'Pass a human device check — human-only, confirmed in the human terminal (B137)',
355
+ },
356
+ {
357
+ id: 'code-forge-add-worker-from-coder',
358
+ kind: 'commandFlag',
359
+ command: ['code-forge', 'run', 'start'],
360
+ groups: [{ anyOf: ['--add-worker'] }],
361
+ description: 'Add a review worker to a run — the orchestrator only (B107)',
362
+ },
349
363
  ].map(freezeEntry),
350
364
  );
351
365
 
@@ -27,7 +27,7 @@
27
27
  */
28
28
 
29
29
  import { execFileSync } from 'node:child_process';
30
- import { mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
30
+ import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
31
31
  import path from 'node:path';
32
32
  import { setTimeout as sleep } from 'node:timers/promises';
33
33
 
@@ -85,7 +85,7 @@ export function isAlive(pid) {
85
85
  export function readStartTime(pid) {
86
86
  if (!isValidPid(pid)) return null;
87
87
  try {
88
- const out = execFileSync('ps', ['-o', 'lstart=', '-p', String(pid)], {
88
+ const out = execFileSync(existsSync('/bin/ps') ? '/bin/ps' : 'ps', ['-o', 'lstart=', '-p', String(pid)], {
89
89
  encoding: 'utf8',
90
90
  stdio: ['ignore', 'pipe', 'ignore'],
91
91
  env: { ...process.env, LC_ALL: 'C', LANG: 'C' },
@@ -165,7 +165,7 @@ export function groupMembers(pgid) {
165
165
  const members = new Map();
166
166
  let out = '';
167
167
  try {
168
- out = execFileSync('ps', ['-A', '-o', 'pid=,pgid=,lstart='], {
168
+ out = execFileSync(existsSync('/bin/ps') ? '/bin/ps' : 'ps', ['-A', '-o', 'pid=,pgid=,lstart='], {
169
169
  encoding: 'utf8',
170
170
  stdio: ['ignore', 'pipe', 'ignore'],
171
171
  env: { ...process.env, LC_ALL: 'C', LANG: 'C' },
@@ -95,13 +95,13 @@ import { askJev } from '../decide/jev-client.mjs';
95
95
  import { planAndRecord, tierOf } from '../review/budget.mjs';
96
96
  import { budgetFor } from '../review/packet.mjs';
97
97
  import { ContextError, reviewDigest } from '../context/pack.mjs';
98
- import { reviewFile, rulesRisk } from '../review/engine.mjs';
98
+ import { reviewFile, reviewLightBatch, rulesRisk } from '../review/engine.mjs';
99
99
  import { reviewAnswers } from '../review/answers.mjs';
100
100
  import { peerDiffReader } from '../review/moved.mjs';
101
101
  import { computeFileSet, ownsFile } from '../gates/scope.mjs';
102
102
  import { extraRoundsFor, newFileState, reopenForExtraRound, runRound } from '../review/fixloop.mjs';
103
103
  import { reopenRowFor } from '../review/reopen.mjs';
104
- import { readRun } from '../state/run.mjs';
104
+ import { processStartTime, readRun } from '../state/run.mjs';
105
105
  import { StateError } from '../state/paths.mjs';
106
106
  import { blockTree, worktreeRecord } from '../proof/worktree.mjs';
107
107
  import { ledgerSchemaHistory } from '../review/schema-fallback.mjs';
@@ -109,7 +109,7 @@ import { verifyRow } from '../state/signer.mjs';
109
109
  import { redact } from '../util/redact.mjs';
110
110
  import { withLock } from '../util/file-locks.mjs';
111
111
  import { blockAnchors, blockRungUsed, loadState, RESTORED_EVENT, saveState } from './review-state.mjs';
112
- import { assertAnswers } from './queue.mjs';
112
+ import { assertAnswers, enqueueHash, isDone, writeResult } from './queue.mjs';
113
113
  import { assertTicketId, contentHash, readRegularFileNoFollow, WorkerError } from './ticket.mjs';
114
114
 
115
115
  /**
@@ -127,6 +127,8 @@ import { assertTicketId, contentHash, readRegularFileNoFollow, WorkerError } fro
127
127
  * the worker's and a per-call `cfg` wins).
128
128
  * @property {(row: Record<string, any>) => Promise<unknown>} [writeRow] - a signed ledger row
129
129
  * (the worker adds `run`, `block`, `file`, `content_hash`).
130
+ * @property {(row: Record<string, any>) => Promise<unknown>} [writeBlockRow] - light fanout rows;
131
+ * the worker pins run and block, while the batch supplies each file and content hash.
130
132
  * @property {Buffer} [key] - the run's HMAC key: signs the fix-loop state. Absent ⇒ engine only.
131
133
  * @property {() => Promise<Array<Record<string, any>>>} [readRows] - the run's ledger rows (the
132
134
  * `review.state` anchors the state is checked against); required with `key`.
@@ -197,7 +199,7 @@ export async function blockEntryFor(runId, block) {
197
199
  throw new Error('worktree-malformed');
198
200
  }
199
201
  }
200
- return { base: tree ? tree.base_sha : sha, level: typeof entry.level === 'string' && /^L[0-3]$/.test(entry.level) ? entry.level : 'L2', owned, ownedKinds, testFiles, acceptance: entry.acceptance, kind: blockKind({ owned, declared: entry.kind }), tree, workspace: typeof record.workspace === 'string' ? record.workspace : null };
202
+ return { base: tree ? tree.base_sha : sha, level: typeof entry.level === 'string' && /^L[0-3]$/.test(entry.level) ? entry.level : 'L2', owned, ownedKinds, testFiles, acceptance: entry.acceptance, kind: entry.kind === 'light' ? 'light' : blockKind({ owned, declared: entry.kind }), tree, workspace: typeof record.workspace === 'string' ? record.workspace : null };
201
203
  }
202
204
 
203
205
  /** @typedef {{path: string, branch: string, base_sha: string}} WorktreeEntry */
@@ -411,6 +413,10 @@ export async function reviewTicket(ticket, ctx) {
411
413
  return { status: 'unavailable', reason: 'context-unreadable', approved: false, engine: 'adaptive', sessions: [] };
412
414
  }
413
415
  const digest = rulesDigest === '' ? {} : { rulesDigest };
416
+ if (entry.kind === 'light' && ctx.key) {
417
+ const batch = await lightRound(ticket, tctx, { entry, workDir, factsExcerpt, rulesDigest });
418
+ if (batch) return batch;
419
+ }
414
420
  if (!ctx.key) {
415
421
  return await reviewFile(
416
422
  { repoRoot: contentRoot, file: ticket.file, base: entry.base, cfg: ctx.cfg, kind: entry.kind, workDir, factsExcerpt, ...digest, peerDiffs, ...(await fileContext()) },
@@ -423,6 +429,56 @@ export async function reviewTicket(ticket, ctx) {
423
429
  }
424
430
  }
425
431
 
432
+ /** Serialize a light block across tickets/workers; durable signed rows enforce the two-batch cap.
433
+ * A batch completes every file's queue ticket, including files queued after this ticket started.
434
+ * @param {import('./queue.mjs').Ticket} ticket @param {ReviewContext} ctx
435
+ * @param {{entry: Awaited<ReturnType<typeof blockEntryFor>>, workDir: string, factsExcerpt: string, rulesDigest: string}} input
436
+ * @returns {Promise<ReviewOutcome | null>} null enters the ordinary per-file loop.
437
+ */
438
+ async function lightRound(ticket, ctx, { entry, workDir, factsExcerpt, rulesDigest }) {
439
+ if (!ctx.key || !ctx.readRows || !ctx.writeBlockRow) return { status: 'unavailable', reason: 'no-ledger', approved: false, engine: 'adaptive', sessions: [] };
440
+ const key = ctx.key, readRows = ctx.readRows, writeRow = ctx.writeBlockRow;
441
+ const worker = { pid: process.pid, started_at: await processStartTime(process.pid) };
442
+ return withLock(`light:${ctx.runId}\0${ticket.block}`, async () => {
443
+ const root = ctx.contentRoot ?? ctx.repoRoot;
444
+ const files = (await computeFileSet({ cwd: root, base: entry.base ?? 'HEAD' })).all.filter((file) => ownsFile(entry.owned, file, entry.ownedKinds)).sort();
445
+ const hashes = files.map((file) => ({ file, content_hash: contentHash(root, file) }));
446
+ if (!hashes.some((f) => f.file === ticket.file && f.content_hash === ticket.content_hash)) return { status: 'stale', approved: false, engine: 'adaptive', sessions: [] };
447
+ const signature = JSON.stringify([entry.base, hashes]);
448
+ const rows = (await readRows()).filter((r) => r.run === ctx.runId && r.block === ticket.block && verifyRow(r, key).ok);
449
+ const cached = rows.filter((r) => r.event === 'review.light_result' && r.signature === signature).at(-1)?.outcomes?.[ticket.file];
450
+ if (cached && (cached.status === 'reviewed' || isDone(ctx.repoRoot, ticket.ticket))) return cached;
451
+ const count = rows.filter((r) => r.event === 'review.light_batch').length;
452
+ if (count >= 2) return null;
453
+ const round = count + 1;
454
+ // Reserve before the session: a crash cannot reset the cap on a worker restart.
455
+ await writeRow({ event: 'review.light_batch', round, signature });
456
+ const batch = await reviewLightBatch({ repoRoot: root, files, base: entry.base, cfg: ctx.cfg, workDir, factsExcerpt, rulesDigest }, { spawn: ctx.spawn, writeRow });
457
+ const moved = hashes.some((f) => contentHash(root, f.file) !== f.content_hash);
458
+ const findings = batch.findings ?? [];
459
+ const blocking = findings.some((f) => f.severity === 'critical' || f.severity === 'warning');
460
+ /** @type {Record<string, ReviewOutcome>} */
461
+ const outcomes = {};
462
+ for (const { file, content_hash } of hashes) {
463
+ const own = findings.filter((f) => f.file === file);
464
+ const approved = !moved && batch.status === 'reviewed' && (batch.approved || blocking) && own.every((f) => f.severity === 'nit');
465
+ const outcome = { ...batch, ...(moved ? { status: 'stale' } : {}), approved, findings: own, round, kind: 'full', level: entry.level,
466
+ next: { action: approved ? 'complete' : 'fix', level: entry.level } };
467
+ outcomes[file] = outcome;
468
+ if (batch.status === 'reviewed' && !moved) {
469
+ await writeRow({ event: 'review.round', file, content_hash, round, kind: 'full', level: entry.level, open_after: own.filter((f) => f.severity !== 'nit').length });
470
+ if (approved) await writeRow({ event: 'review.approved', file, content_hash });
471
+ }
472
+ const queued = enqueueHash({ repoRoot: ctx.repoRoot, run: ctx.runId, block: ticket.block, file, content_hash, ...(ticket.tree ? { tree: ticket.tree } : {}) });
473
+ const open = approved ? [] : own;
474
+ await writeRow({ event: 'review.result', ticket: queued.ticket, file, content_hash, status: outcome.status, approved, open, worker });
475
+ writeResult({ repoRoot: ctx.repoRoot, runId: ctx.runId, ticket: queued.ticket, result: { ...outcome, block: ticket.block, file, content_hash, open, worker }, key });
476
+ }
477
+ await writeRow({ event: 'review.light_result', signature, outcomes });
478
+ return outcomes[ticket.file];
479
+ });
480
+ }
481
+
426
482
  /**
427
483
  * B56: the block's OTHER changed files — the §4.1 file set since the block base (tracked changes ∪
428
484
  * untracked), filtered to the block's owned files (`ownsFile`, as the budget row does), minus the