@codedology/code-forge 0.14.1 → 0.15.1

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 (41) hide show
  1. package/CHANGELOG.md +29 -0
  2. package/README.md +1 -1
  3. package/docs/reference/blocks.json +495 -1
  4. package/docs/reference/config.md +7 -0
  5. package/package.json +1 -1
  6. package/schema/code-forge.schema.json +22 -0
  7. package/scripts/first-use.mjs +11 -5
  8. package/skill/SKILL.md +4 -0
  9. package/skill/references/autopilot.md +2 -0
  10. package/skill/references/code.md +28 -2
  11. package/skill/references/review.md +10 -2
  12. package/skill/references/security.md +2 -0
  13. package/skill/scripts/forge +1 -1
  14. package/skill/templates/coder-brief.md +3 -1
  15. package/src/autopilot/scopes.mjs +2 -1
  16. package/src/cli/block.mjs +271 -50
  17. package/src/cli/gates.mjs +36 -6
  18. package/src/cli/ledger.mjs +21 -2
  19. package/src/cli/report.mjs +14 -0
  20. package/src/cli/review-file.mjs +56 -16
  21. package/src/cli/run.mjs +44 -7
  22. package/src/cli/status.mjs +7 -3
  23. package/src/config/validate.mjs +13 -1
  24. package/src/gates/acceptance.mjs +7 -3
  25. package/src/gates/expand.mjs +25 -2
  26. package/src/gates/run.mjs +38 -8
  27. package/src/ledger/report.mjs +29 -7
  28. package/src/review/accept-hit.mjs +22 -4
  29. package/src/review/engine.mjs +30 -10
  30. package/src/review/finding.schema.json +2 -1
  31. package/src/review/fixloop.mjs +79 -14
  32. package/src/review/gate-check.mjs +33 -7
  33. package/src/review/lenses/recheck.md +3 -0
  34. package/src/review/packet.mjs +26 -0
  35. package/src/state/block.mjs +196 -22
  36. package/src/state/run.mjs +35 -8
  37. package/src/util/forbidden.mjs +230 -6
  38. package/src/util/reaper.mjs +3 -3
  39. package/src/worker/engine.mjs +74 -6
  40. package/src/worker/loop.mjs +19 -10
  41. package/src/worker/queue.mjs +38 -8
@@ -139,7 +139,7 @@ export function checkBlockReviews({ block, runId, files, rows, key, transcript =
139
139
  /** @type {Map<string, string>} */
140
140
  const current = new Map();
141
141
  for (const f of [...proofFiles, ...files]) current.set(f.file, f.content_hash);
142
- const { mine, latestOf, signedOf, matches, waiverOf, waiverFor, waivedAllOf, recordedRefusalSeverity, supersededCap } = reviewRules({ block, runId, rows, key, resultFiles, current });
142
+ const { mine, acceptanceAmended, latestOf, signedOf, matches, waiverOf, waiverFor, waivedAllOf, recordedRefusalSeverity, supersededCap } = reviewRules({ block, runId, rows, key, resultFiles, current });
143
143
  /** @type {Refusal[]} */
144
144
  const refusals = [];
145
145
  const approvedFor = (/** @type {string} */ file, /** @type {string} */ hash) => mine.filter((r) => r.event === 'review.approved' && r.file === file && r.content_hash === hash);
@@ -169,7 +169,7 @@ export function checkBlockReviews({ block, runId, files, rows, key, transcript =
169
169
  else {
170
170
  // B73: a stopped file (a cap row of the block) names its ways out
171
171
  const exits = mine.some((r) => r.event === 'review.cap' && r.file === file) ? `; ${stopExitsLine}` : '';
172
- refusals.push({ code: 'unreviewed', file, detail: `no review.approved row for ${file} at its current content${exits}` });
172
+ refusals.push({ code: 'unreviewed', file, detail: `no review.approved row for ${file} at its current content${acceptanceAmended ? ' (acceptance amended)' : ''}${exits}` });
173
173
  }
174
174
  } else if (!signedApproval) refusals.push({ code: 'unsigned', file, detail: `the review.approved row for ${file} has no valid MAC` });
175
175
  if (signedApproval) {
@@ -237,6 +237,24 @@ export function checkBlockReviews({ block, runId, files, rows, key, transcript =
237
237
  return { ok: refusals.length === 0, refusals, waivedAll: refusals.length === 0 ? waivedAll : [], ...(acceptedHits.length > 0 ? { acceptedHits } : {}) };
238
238
  }
239
239
 
240
+ /** B138: delegation never turns a waived or answered file into an approval.
241
+ * @param {Parameters<typeof checkBlockReviews>[0]} opts
242
+ * @returns {string | null}
243
+ */
244
+ export function routineCloseProblem(opts) {
245
+ const mine = opts.rows.filter((r) => r.block === opts.block && r.run === opts.runId);
246
+ if (mine.some((r) => typeof r.event === 'string' && r.event.includes('waiv'))) return 'waiver rows for block';
247
+ if (mine.some((r) => r.event === 'rule_break' || r.event === 'rule_break.accepted')) return 'rule_break or accepted hit for block';
248
+ const hits = acceptedTranscriptHits({ ...opts, transcript: opts.transcript ?? null, rows: [] }).remaining;
249
+ if (hits.length > 0) return `rule_break transcript line ${hits[0].line}: ${hits[0].id}`;
250
+ if (!mine.some((r) => r.event === 'gate.done' && r.all_ok === true && verified(r, opts.key))) return 'no signed green gate row';
251
+ const result = checkBlockReviews(opts);
252
+ if (!result.ok) return result.refusals.map((r) => `${r.code}${r.file ? ` ${r.file}` : ''}`).join('; ');
253
+ if (result.waivedAll.length > 0) return 'reviewed_by_waiver or answered-only file';
254
+ if (opts.files.length === 0) return 'no approved changed files';
255
+ return null;
256
+ }
257
+
240
258
  /** B71: the signed row the worker writes for a finding a reviewer judged resolved by another owned file. */
241
259
  export const ANSWERED_EVENT = 'review.answered';
242
260
 
@@ -261,7 +279,14 @@ function reviewRules({ block, runId, rows, key, resultFiles = {}, current, verif
261
279
  // B71: `verify` replaces the MAC check only for `review-file`'s unverified pre-check ({@link
262
280
  // namedOnHash}), which has no key and can only refuse; it then reads no autopilot waiver
263
281
  const ok = verify ?? ((/** @type {Record<string, any>} */ r) => verified(r, key));
264
- const mine = rows.filter((r) => r?.block === block && (runId === undefined || r.run === undefined || r.run === runId));
282
+ let amendedAt = -1;
283
+ rows.forEach((r, i) => {
284
+ if (r?.event === 'block.acceptance_amended' && r.block === block && r.run === runId && ok(r)) amendedAt = i;
285
+ });
286
+ const acceptanceAmended = amendedAt >= 0;
287
+ // Acceptance is a review input. Every older review verdict belongs to the previous input.
288
+ const mine = rows.filter((r, i) => r?.block === block && (runId === undefined || r.run === undefined || r.run === runId)
289
+ && !(i < amendedAt && typeof r.event === 'string' && r.event.startsWith('review.')));
265
290
  /** A verified human or agreed agent reopen followed by a real review clears older cap ids.
266
291
  * @param {Record<string, any>} cap @returns {boolean}
267
292
  */
@@ -499,7 +524,7 @@ function reviewRules({ block, runId, rows, key, resultFiles = {}, current, verif
499
524
  return new Map([...named].filter(([id, severity]) => id !== PROOF_FINDING && answerableSeverity(severity) && waiverOf(file, id, severity, ticket).by === null));
500
525
  };
501
526
 
502
- return { mine, latestOf, signedOf, matches, waiverOf, waiverFor, waivedAllOf, openAt, answerRows, recordedRefusalSeverity, supersededCap };
527
+ return { mine, acceptanceAmended, latestOf, signedOf, matches, waiverOf, waiverFor, waivedAllOf, openAt, answerRows, recordedRefusalSeverity, supersededCap };
503
528
  }
504
529
 
505
530
  /**
@@ -639,15 +664,16 @@ export function openFindings(findings) {
639
664
  * B52: the signed result files {@link denialOf} needs — one per `review.result` row of the block
640
665
  * that has no `approved` field (an old ledger) and is a verdict — keyed by ticket. A file that is
641
666
  * missing, unparsable, names another run or ticket, or fails its MAC is null.
642
- * @param {{repoRoot: string, runId: string, block: string, rows: Array<Record<string, any>>}} opts
667
+ * @param {{repoRoot: string, runId: string, block: string, rows: Array<Record<string, any>>, includeCurrent?: boolean}} opts
643
668
  * `repoRoot`: the repository root the worker writes `.code-forge/reviews/` under.
669
+ * `includeCurrent`: B138 decision lists also need claims from modern signed results.
644
670
  * @returns {Promise<Record<string, ResultFile>>}
645
671
  */
646
- export async function resultFilesFor({ repoRoot, runId, block, rows }) {
672
+ export async function resultFilesFor({ repoRoot, runId, block, rows, includeCurrent = false }) {
647
673
  /** @type {Record<string, ResultFile>} */
648
674
  const out = {};
649
675
  for (const r of rows) {
650
- if (r?.event !== 'review.result' || r.block !== block || Object.hasOwn(r, 'approved') || neverReviewed(r) || typeof r.ticket !== 'string' || Object.hasOwn(out, r.ticket)) continue;
676
+ if (r?.event !== 'review.result' || r.block !== block || (!includeCurrent && Object.hasOwn(r, 'approved')) || neverReviewed(r) || typeof r.ticket !== 'string' || Object.hasOwn(out, r.ticket)) continue;
651
677
  try {
652
678
  const checked = await verifyResult(repoRoot, runId, r.ticket);
653
679
  out[r.ticket] = checked.ok && checked.result ? { status: checked.result.status, approved: checked.result.approved, findings: checked.result.findings } : null;
@@ -10,6 +10,9 @@ that changed since that round, each with a window of the current file around it.
10
10
  - For every open finding, add one entry to `resolved`: `{id, resolved: true|false, why}`
11
11
  (`why` ≤ 200 chars). `resolved: true` only when the shown lines fix the problem without
12
12
  introducing a new one.
13
+ - For `resolved: false`, you MUST add `current_lines` (≤ 300 chars): quote the exact current
14
+ line(s) from the packet that still show the problem, without line-number prefixes. Read the
15
+ changed lines; the old finding's claim and evidence alone cannot keep it open.
13
16
  - A new problem goes in `findings` ONLY when its `line_start..line_end` lies inside a shown fix
14
17
  hunk. Anything outside the hunks is not yours to report in this round.
15
18
  - `passed` is true only when every open finding is resolved and you raised no new `critical`
@@ -223,6 +223,32 @@ export function assemblePacket({ diff, lens, rulesDigest = '', factsExcerpt = ''
223
223
  return { status: 'ok', text, hunkHeaders, contextMode: ctx.mode, contextLines: ctx.lineCount, digestLines: digest.length, tokensIn, budget: limit, overBudget: tokensIn > limit };
224
224
  }
225
225
 
226
+ /** One full-budget packet for a light block; every diff and path heading is retained.
227
+ * @param {{diffs: FileDiff[], cfg?: Record<string, any>, rulesDigest?: string, factsExcerpt?: string}} opts
228
+ */
229
+ export function assembleLightPacket({ diffs, cfg, rulesDigest = '', factsExcerpt = '' }) {
230
+ const lens = `${loadLens('full')}\nReview ALL files below in one round. Findings must name their exact file path. Echo every listed hunk in file order, including duplicates.`;
231
+ const diffText = diffs.map((diff) => `### ${diff.file}\n${diffSectionOf(diff)}`).join('\n\n');
232
+ const budget = budgetFor(cfg, 'full_in');
233
+ let digest = splitLines(rulesDigest).slice(0, DIGEST_MAX_LINES);
234
+ const context = (mode) => diffs.map((diff) => `### ${diff.file}\n${buildContext({ file: diff.file, content: diff.content, hunks: diff.hunks, cfg, mode }).text}`).join('\n\n');
235
+ let contextMode = 'auto';
236
+ let ctx = context(contextMode);
237
+ let text = render(lens, digest, factsExcerpt, ctx, diffText);
238
+ if (estimateTokens(text) > budget) {
239
+ contextMode = 'minimal';
240
+ ctx = context(contextMode);
241
+ text = render(lens, digest, factsExcerpt, ctx, diffText);
242
+ }
243
+ while (estimateTokens(text) > budget && digest.length > 0) {
244
+ digest = digest.slice(0, -1);
245
+ text = render(lens, digest, factsExcerpt, ctx, diffText);
246
+ }
247
+ const tokensIn = estimateTokens(text);
248
+ return { status: tokensIn > budget ? 'split_required' : 'ok', text, tokensIn, budget,
249
+ contextMode, hunkHeaders: diffs.flatMap((diff) => diff.hunks.map((h) => h.header)) };
250
+ }
251
+
226
252
  /** Cap the entire section at full_in / 16 tokens, without cutting a UTF-8 character.
227
253
  * @param {string} paired @param {number} fullIn @returns {string}
228
254
  */
@@ -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';
@@ -24,14 +24,16 @@ import { isAncestorArgv, revParseArgv } from '../util/git.mjs';
24
24
  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
- import { verifyRow, loadKey } from './signer.mjs';
27
+ import { canonicalJSON, 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
 
31
33
  export const BRIEF_POINTER_MAX_BYTES = 200;
32
34
 
33
35
  /** Ledger events the block gate requires to carry a valid MAC (§4.6 row 10). */
34
- export const GATE_EVENTS = Object.freeze(['dispatch', 'block.attempt', 'block.rebase', 'block.claim', 'review.approved', 'proof']);
36
+ export const GATE_EVENTS = Object.freeze(['dispatch', 'block.attempt', 'block.rebase', 'block.claim', 'block.acceptance_amended', 'review.approved', 'proof']);
35
37
 
36
38
  const BLOCK_ID = /^[A-Za-z0-9][A-Za-z0-9._-]{0,31}$/;
37
39
  const LEVEL = /^L[0-3]$/;
@@ -216,19 +218,53 @@ 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
 
236
+ /** Validate before the human question, and again under the run lock.
237
+ * @param {Record<string, any>} record @param {string} id @param {unknown} clauses
238
+ */
239
+ export function prepareAcceptanceAmendment(record, id, clauses) {
240
+ const block = record.blocks?.[id];
241
+ if (!block) throw new StateError('unknown_block', `block ${id} is not in run ${record.run_id}`);
242
+ if (block.status !== 'open') throw new StateError('not-open', `not-open: block ${id} is not open`);
243
+ const acceptance = assertAcceptance(clauses);
244
+ const old_sha256 = sha256(canonicalJSON(block.acceptance));
245
+ const new_sha256 = sha256(canonicalJSON(acceptance));
246
+ if (old_sha256 === new_sha256) throw new StateError('acceptance-unchanged', 'acceptance-unchanged: the clause list is unchanged; nothing was recorded');
247
+ return { acceptance, old_sha256, new_sha256, oldCount: block.acceptance.length };
248
+ }
249
+
250
+ /** @param {{runId: string, id: string, acceptance: unknown, reason: string, old_sha256: string, writeRow: WriteRow, before: (record: Record<string, any>) => void}} opts */
251
+ export async function amendAcceptanceBlock({ runId, id, acceptance, reason, old_sha256, writeRow, before }) {
252
+ return mutateRun(runId, null, writeRow, (record) => {
253
+ before(record);
254
+ const prepared = prepareAcceptanceAmendment(record, id, acceptance);
255
+ if (prepared.old_sha256 !== old_sha256) throw new StateError('acceptance-moved', `acceptance changed while confirming block ${id}; confirm again`);
256
+ const block = record.blocks[id];
257
+ block.acceptance = prepared.acceptance;
258
+ const checks = prepared.acceptance.filter((c) => c.kind === 'human_check').map((c) => c.clause);
259
+ if (checks.length) block.human_checks = checks;
260
+ else delete block.human_checks;
261
+ // Unique even when a later amendment restores an earlier list of clauses.
262
+ block.acceptance_revision = randomUUID();
263
+ const row = { event: 'block.acceptance_amended', block: id, old_sha256, new_sha256: prepared.new_sha256, clauses: prepared.acceptance.length, reason, by: 'human' };
264
+ return { result: row, rows: [row] };
265
+ });
266
+ }
267
+
232
268
  /**
233
269
  * Under the run lock: load the run, check it is active and (when `id` is given) that the block
234
270
  * is open, run `mutate`, write its signed `rows` through the ledger writer, THEN save the record —
@@ -315,6 +351,7 @@ function refuseConfigOwner(owned, kinds, guarded) {
315
351
  * and the dispatch row carry the floor level, and the row adds `requested_level` and
316
352
  * `trigger: 'docs_floor'`. B52: the entry also records `tree_at_open` ({@link treeAtOpen}), the
317
353
  * tree's changes at open, which `block close` reads to tell the coder's changes from earlier ones.
354
+ * B134: `base_branch` is the workspace's current branch, or null when detached.
318
355
  * B58: with `review.external.required` set, an owned entry that owns the project's config is
319
356
  * refused (`config-owned`, {@link refuseConfigOwner}).
320
357
  * B59: `decision` (the rules lane of a brief-mode block) is written as a signed `decision` row
@@ -352,12 +389,13 @@ export async function openBlock(opts) {
352
389
  if (typeof level !== 'string' || !LEVEL.test(level)) throw new StateError('bad-level', 'level must be L0..L3');
353
390
  if (!Number.isInteger(attempt) || attempt < 1) throw new StateError('bad-attempt', 'attempt must be an integer >= 1');
354
391
  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(', ')}`);
392
+ 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
393
  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
394
  const owned = assertOwned(opts.owned);
358
395
  const acceptance = assertAcceptance(opts.acceptance);
396
+ const humanChecks = acceptance.filter((c) => c.kind === 'human_check').map((c) => c.clause);
359
397
  const tests = opts.tests === undefined ? null : [...assertOwned(opts.tests, { label: 'test files', code: 'bad-tests' })];
360
- const kind = blockKind({ owned, declared: opts.kind });
398
+ const kind = opts.kind === 'light' ? 'light' : blockKind({ owned, declared: opts.kind });
361
399
  const dispatched = dispatchLevel({ lane: level, kind, cfg: opts.cfg });
362
400
  const pointer = opts.brief ? briefPointer(opts.brief.path, opts.brief.content) : null;
363
401
 
@@ -384,6 +422,7 @@ export async function openBlock(opts) {
384
422
  try {
385
423
  const { block, reused } = await mutateRun(runId, null, writeRow, async (record) => {
386
424
  if (record.blocks[id]?.status === 'open') throw new StateError('already-open', `block ${id} is already open`);
425
+ if (record.blocks[id]?.status === 'awaiting_human_check') throw new StateError('already-open', `block ${id} is awaiting a human check`);
387
426
  // B57: what each exact owned entry is on disk, under the lock (as `block claim`) — a regular
388
427
  // file owns only itself, here and in every later check
389
428
  const kinds = ownedKinds(record.workspace, owned);
@@ -391,6 +430,8 @@ export async function openBlock(opts) {
391
430
  const prior = previousTree(record);
392
431
  const baseSha = await resolveCommit(prior ? prior.base_sha : opts.base ?? 'HEAD', record.workspace);
393
432
  if (baseSha !== stampedBase || record.workspace !== workspace) throw new StateError('base-moved', `the base moved while block ${id} was being opened; run block open again`);
433
+ const branch = await git(['git', 'symbolic-ref', '--quiet', '--short', 'HEAD'], record.workspace, [0, 1]);
434
+ const baseBranch = branch.code === 0 ? branch.stdout.trim() : null;
394
435
  // B62a: the tree is planned (read-only checks) before the overlap check, which needs its path
395
436
  const planned = opts.worktree ? await opts.worktree({ baseSha, previous: prior }) : null;
396
437
  const self = { owned_files: owned, owned_kinds: kinds, worktree: planned ? { path: planned.path } : null };
@@ -402,7 +443,8 @@ export async function openBlock(opts) {
402
443
  // passed; a tree made here is rolled back below if the rows or the save then fail
403
444
  made = planned ? await planned.make() : null;
404
445
  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 } : {}) };
446
+ 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 } : {}) };
447
+ if (humanChecks.length > 0) Object.assign(entry, { human_checks: humanChecks });
406
448
  record.blocks[id] = entry;
407
449
  const floor = dispatched.trigger === 'docs_floor' ? { requested_level: level, trigger: 'docs_floor' } : {};
408
450
  const created = tree?.created ? [{ event: 'worktree.created', block: id, path: tree.worktree.path, branch: tree.worktree.branch, base: tree.worktree.base_sha }] : [];
@@ -411,7 +453,7 @@ export async function openBlock(opts) {
411
453
  rows: [
412
454
  ...(opts.decision ? [{ ...opts.decision, event: 'decision', decision_id: opts.decisionId ?? randomUUID(), block: id }] : []),
413
455
  ...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 } : {}) },
456
+ { 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
457
  ],
416
458
  };
417
459
  });
@@ -444,11 +486,14 @@ export async function attemptBlock({ runId, id, writeRow }) {
444
486
  * in between touched a file this block owns (that is a conflict ⇒ stop). Rename detection is OFF
445
487
  * (`--no-renames`): a rename lists both its old and its new path, so moving an owned file away
446
488
  * 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`).
489
+ * below it). B62a: refused for an open block with its own worktree (`worktree-rebase`).
490
+ * B134: a closed worktree block instead rebases its branch onto its recorded base branch,
491
+ * retaining every changed owned file's blob; a conflict or changed blob restores the old head.
448
492
  * @param {{runId: string, id: string, head?: string, writeRow: WriteRow}} opts
449
493
  * @returns {Promise<{old_base: string, new_base: string}>}
450
494
  */
451
495
  export async function rebaseBlock({ runId, id, head = 'HEAD', writeRow }) {
496
+ if (['closed', 'awaiting_human_check'].includes((await readRun(runId)).blocks[id]?.status)) return rebaseClosedBlock({ runId, id, writeRow });
452
497
  return mutateRun(runId, id, writeRow, async (record, block) => {
453
498
  // B62a: a worktree block's base is the commit its tree started from; moving it would leave the
454
499
  // tree on its old base, so the rebase is refused
@@ -466,6 +511,75 @@ export async function rebaseBlock({ runId, id, head = 'HEAD', writeRow }) {
466
511
  });
467
512
  }
468
513
 
514
+ /**
515
+ * Hash committed paths, with null for a deletion. NUL-delimited tree entries preserve unusual
516
+ * file names; filter exact names because git's path selection may also return descendants.
517
+ * @param {string} cwd @param {string} ref @param {string[]} files
518
+ * @returns {Promise<Map<string, string | null>>}
519
+ */
520
+ async function committedBlobs(cwd, ref, files) {
521
+ const hashes = new Map(files.map((file) => [file, /** @type {string | null} */ (null)]));
522
+ if (files.length === 0) return hashes;
523
+ const tree = await git(['git', 'ls-tree', '-r', '-z', '--full-tree', ref, '--', ...files], cwd);
524
+ for (const entry of tree.stdout.split('\0').filter(Boolean)) {
525
+ const tab = entry.indexOf('\t');
526
+ const file = entry.slice(tab + 1);
527
+ if (hashes.has(file)) hashes.set(file, entry.slice(0, tab).split(' ')[2]);
528
+ }
529
+ return hashes;
530
+ }
531
+
532
+ /**
533
+ * Closed blocks have a separate mutation path: check their status again under the lock,
534
+ * restore the branch on failure (including a failed signed write), and retain closed/awaiting status.
535
+ * @param {{runId: string, id: string, writeRow: WriteRow}} opts
536
+ * @returns {Promise<{old_base: string, new_base: string}>}
537
+ */
538
+ async function rebaseClosedBlock({ runId, id, writeRow }) {
539
+ return withRunLock(runId, async () => {
540
+ const record = await readRun(runId);
541
+ if (record.status !== 'active') throw new StateError('run-ended', `run ${runId} has ended`);
542
+ const block = record.blocks[id];
543
+ if (!['closed', 'awaiting_human_check'].includes(block?.status) || !block.worktree) throw new StateError('no-block', `block ${id} is not open in run ${runId}`);
544
+ if (!block.base_branch) throw new StateError('rebase-no-base', `block ${id} has no recorded base branch`);
545
+ const tree = block.worktree.path;
546
+ const branch = await git(['git', 'symbolic-ref', '--quiet', 'HEAD'], tree, [0, 1]);
547
+ 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}`);
548
+ // A reset must never discard edits made after close, or abort an earlier rebase.
549
+ const status = await git(['git', 'status', '--porcelain'], tree);
550
+ if (status.stdout.length > 0) throw new StateError('rebase-conflict', 'rebase-conflict');
551
+ const oldHead = await resolveCommit('HEAD', tree);
552
+ const newBase = await resolveCommit(`refs/heads/${block.base_branch}`, record.workspace);
553
+ const top = await repoTop(record.workspace);
554
+ const prefix = path.relative(top, realpathSync(record.workspace)).split(path.sep).join('/');
555
+ const diff = await git(['git', 'diff', '--no-renames', '--name-only', '-z', block.base_sha, oldHead, '--'], tree);
556
+ const files = diff.stdout.split('\0').filter((file) => file.length > 0 && (
557
+ ownsFile(block.owned_files, file, block.owned_kinds) ||
558
+ (prefix !== '' && file.startsWith(`${prefix}/`) && ownsFile(block.owned_files, file.slice(prefix.length + 1), block.owned_kinds))
559
+ )).sort();
560
+ const before = await committedBlobs(tree, oldHead, files);
561
+ const moved = { old_base: block.base_sha, new_base: newBase };
562
+ try {
563
+ const rebased = await exec(['git', '-C', tree, 'rebase', newBase], { cwd: record.workspace, env: gitEnv(), timeoutMs: 30000 });
564
+ if (rebased.result !== 'ok') throw new StateError('rebase-conflict', 'rebase-conflict');
565
+ const after = await committedBlobs(tree, 'HEAD', files);
566
+ const changed = files.filter((file) => before.get(file) !== after.get(file));
567
+ if (changed.length > 0) throw new StateError('rebase-changed-reviewed', `rebase-changed-reviewed ${changed.join(', ')}`);
568
+ block.base_sha = newBase;
569
+ block.worktree.base_sha = newBase;
570
+ await writeSigned(runId, writeRow, { event: 'block.rebase', block: id, ...moved, closed: true });
571
+ await saveRun(record);
572
+ return moved;
573
+ } catch (err) {
574
+ // --abort restores a conflicted rebase; after a completed rebase there is nothing to
575
+ // abort, and reset restores the old branch head. The tree was clean before either.
576
+ await exec(['git', '-C', tree, 'rebase', '--abort'], { cwd: record.workspace, env: gitEnv(), timeoutMs: 30000 });
577
+ await git(['git', 'reset', '--hard', oldHead], tree);
578
+ throw err;
579
+ }
580
+ });
581
+ }
582
+
469
583
  /**
470
584
  * `block claim` — assign one EXACT path (an orphan, or any unowned path) to `id`; refused for a
471
585
  * glob, and when another open block owns it — or anything it would own: the claimed path becomes an
@@ -513,13 +627,35 @@ export async function checkScope({ runId, id, files, writeRow }) {
513
627
 
514
628
  /** @param {{runId: string, id: string, reason: string, writeRow: WriteRow, event?: string}} opts */
515
629
  export async function stopBlock({ runId, id, reason, writeRow, event = 'block.stop' }) {
516
- await mutateRun(runId, id, writeRow, (_record, block) => {
630
+ await mutateRun(runId, null, writeRow, (record) => {
631
+ const block = record.blocks[id];
632
+ if (!['open', 'awaiting_human_check'].includes(block?.status)) throw new StateError('no-block', `block ${id} is not open in run ${runId}`);
517
633
  block.status = 'stopped';
518
634
  block.stop_reason = reason;
519
635
  return { result: undefined, rows: [{ event, block: id, reason }] };
520
636
  });
521
637
  }
522
638
 
639
+ /**
640
+ * Called only after the CLI's terminal confirmation; recheck under the lock before signing.
641
+ * @param {{runId: string, id: string, note: string, proof?: string | null, writeRow: WriteRow, now?: Date}} opts
642
+ */
643
+ export async function humanPassBlock({ runId, id, note, proof = null, writeRow, now = new Date() }) {
644
+ return mutateRun(runId, null, writeRow, (record) => {
645
+ const block = record.blocks[id];
646
+ if (block?.status !== 'awaiting_human_check') throw new StateError('not-awaiting', `block ${id} is not awaiting a human check`);
647
+ if (typeof note !== 'string' || note.trim().length === 0) throw new StateError('usage', 'block human-pass needs --note <text>');
648
+ if (proof !== null && (typeof proof !== 'string' || !existsSync(proof))) throw new StateError('usage', '--proof must name an existing directory or file');
649
+ const grant = record.autopilot;
650
+ if (grant && !['stopped', 'expired'].includes(grant.status) && !(now.getTime() >= Date.parse(grant.until))) {
651
+ throw new StateError('autopilot-active', `block human-pass is refused while an autopilot grant is active on run ${runId}`);
652
+ }
653
+ const row = { event: 'block.human_pass', block: id, note, proof, checks: [...block.human_checks], by: 'human' };
654
+ block.status = 'closed';
655
+ return { result: row, rows: [row] };
656
+ });
657
+ }
658
+
523
659
  /**
524
660
  * B8's rows of the block gate, in order: worker pin ⇒ `worker.replaced`/`worker.down` (block
525
661
  * stopped); the block's signed `dispatch` row must exist and verify, and every other
@@ -533,16 +669,44 @@ export async function stopBlock({ runId, id, reason, writeRow, event = 'block.st
533
669
  * livePid?: number, probe?: import('./run.mjs').StartTimeProbe,
534
670
  * extraChecks?: Array<() => Promise<{ok: boolean, reason?: string}>>,
535
671
  * closeRows?: Record<string, any>[],
672
+ * dryRun?: boolean,
536
673
  * }} opts
537
- * @returns {Promise<{ok: boolean, status: 'closed' | 'stopped' | 'open', reason?: string, event?: string}>}
674
+ * @returns {Promise<{ok: boolean, status: 'closed' | 'awaiting_human_check' | 'stopped' | 'open', reason?: string, event?: string}>}
538
675
  */
539
- export async function closeBlock({ runId, id, rows, writeRow, livePid, probe, extraChecks = [], closeRows = [] }) {
676
+ export async function closeBlock({ runId, id, rows, writeRow, livePid, probe, extraChecks = [], closeRows = [], dryRun = false }) {
540
677
  const record = await readRun(runId);
541
678
  if (record.blocks[id]?.status !== 'open') throw new StateError('no-block', `block ${id} is not open in run ${runId}`);
542
679
 
543
- const pin = await checkWorkerPin(record, { livePid, probe });
680
+ let pin = await checkWorkerPin(record, { livePid, probe });
681
+ const pins = record.workers ?? (record.worker ? [record.worker] : []);
682
+ 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'));
683
+ // Ledgers written before B107 omit worker identity and use the run's pin check above.
684
+ if (counted.some((row) => row.worker !== undefined && !pins.some((p) => p.pid === row.worker?.pid && p.started_at === row.worker?.started_at))) {
685
+ pin = { ok: false, event: 'worker.replaced', reason: 'a counted review result does not name a pinned worker' };
686
+ }
687
+ if (pin.ok) {
688
+ const repoRoot = await repoRootOf(record.workspace);
689
+ let names;
690
+ try { names = readdirSync(reviewQueue.queueDir(repoRoot)); }
691
+ catch (err) { if (err.code !== 'ENOENT') throw err; names = []; }
692
+ for (const name of names.filter((n) => /^[0-9a-f]{24}\.json$/.test(n))) {
693
+ const ticket = name.slice(0, 24);
694
+ const queued = reviewQueue.readTicket(repoRoot, ticket);
695
+ if (queued.run !== runId || queued.block !== id) continue;
696
+ const claimKey = `ticket\0${runId}\0${ticket}`;
697
+ const file = path.join(os.homedir(), '.code-forge', 'locks', `${createHash('sha256').update(claimKey).digest('hex')}.lock`);
698
+ let claim;
699
+ try { claim = JSON.parse(readFileSync(file, 'utf8')); }
700
+ catch (err) { if (err.code === 'ENOENT') continue; throw err; }
701
+ const owner = pins.find((p) => p.pid === claim.pid && p.started_at === claim.start);
702
+ if (owner && !(await checkWorkerPin({ worker: owner }, { probe })).ok) {
703
+ pin = { ok: false, event: 'worker.down', reason: `dead pinned worker pid ${owner.pid} holds ticket ${ticket}` };
704
+ break;
705
+ }
706
+ }
707
+ }
544
708
  if (!pin.ok) {
545
- await stopBlock({ runId, id, reason: pin.reason, writeRow, event: pin.event });
709
+ if (!dryRun) await stopBlock({ runId, id, reason: pin.reason, writeRow, event: pin.event });
546
710
  return { ok: false, status: 'stopped', event: pin.event, reason: pin.reason };
547
711
  }
548
712
 
@@ -555,25 +719,35 @@ export async function closeBlock({ runId, id, rows, writeRow, livePid, probe, ex
555
719
  const reasons = bad.map((x) => `${x.row.event}: ${x.result.reason}`);
556
720
  if (!dispatchOk) reasons.push('no verified dispatch row');
557
721
  const reason = reasons.join('; ');
558
- await stopBlock({ runId, id, reason, writeRow, event: 'ledger.tamper' });
722
+ if (!dryRun) await stopBlock({ runId, id, reason, writeRow, event: 'ledger.tamper' });
559
723
  return { ok: false, status: 'stopped', event: 'ledger.tamper', reason };
560
724
  }
561
725
 
726
+ const failures = [];
562
727
  for (const check of extraChecks) {
563
728
  const result = await check();
564
- if (!result.ok) return { ok: false, status: 'open', reason: result.reason ?? 'check failed' };
729
+ if (!result.ok) {
730
+ if (!dryRun) return { ok: false, status: 'open', reason: result.reason ?? 'check failed' };
731
+ failures.push(result.reason ?? 'check failed');
732
+ }
733
+ }
734
+ if (dryRun) {
735
+ if (record.orphans.length > 0) failures.push(`orphans: ${record.orphans.join(', ')}`);
736
+ return { ok: failures.length === 0, status: 'open', ...(failures.length ? { reason: failures.join('; ') } : {}) };
565
737
  }
566
738
 
567
- /** @typedef {{ok: boolean, status: 'closed' | 'stopped' | 'open', reason?: string, event?: string}} CloseResult */
739
+ /** @typedef {{ok: boolean, status: 'closed' | 'awaiting_human_check' | 'stopped' | 'open', reason?: string, event?: string}} CloseResult */
568
740
  return mutateRun(runId, id, writeRow, (current, block) => {
569
741
  if (current.orphans.length > 0) {
570
742
  /** @type {CloseResult} */
571
743
  const refused = { ok: false, status: 'open', reason: `orphans: ${current.orphans.join(', ')}` };
572
744
  return { result: refused, rows: [] };
573
745
  }
574
- block.status = 'closed';
746
+ const checks = block.human_checks ?? [];
747
+ block.status = checks.length > 0 ? 'awaiting_human_check' : 'closed';
575
748
  /** @type {CloseResult} */
576
- const closed = { ok: true, status: 'closed' };
577
- return { result: closed, rows: [...closeRows, { event: 'block.close', block: id, status: 'complete' }] };
749
+ const closed = { ok: true, status: block.status };
750
+ return { result: closed, rows: [...closeRows, { event: 'block.close', block: id, status: 'complete' },
751
+ ...(checks.length > 0 ? [{ event: 'block.awaiting_human_check', block: id, checks }] : [])] };
578
752
  });
579
753
  }
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
  });