@codedology/code-forge 0.15.2 → 0.15.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (79) hide show
  1. package/CHANGELOG.md +81 -0
  2. package/README.md +5 -1
  3. package/docs/README.md +2 -0
  4. package/docs/reference/blocks.json +162 -1
  5. package/docs/reference/config.md +38 -15
  6. package/native/README.md +10 -0
  7. package/native/bin/darwin-arm64/code-forge-native +0 -0
  8. package/native/bin/darwin-x64/code-forge-native +0 -0
  9. package/native/bin/linux-arm64/code-forge-native +0 -0
  10. package/native/bin/linux-x64/code-forge-native +0 -0
  11. package/package.json +4 -1
  12. package/schema/code-forge.schema.json +83 -3
  13. package/schema/outcome.schema.json +45 -0
  14. package/schema/pipeline.schema.json +30 -0
  15. package/scripts/first-use.mjs +8 -0
  16. package/scripts/gen-config-doc.mjs +1 -1
  17. package/skill/references/autopilot.md +6 -1
  18. package/skill/references/code.md +52 -0
  19. package/skill/references/ledger.md +13 -1
  20. package/skill/references/plan.md +5 -1
  21. package/skill/references/review.md +46 -8
  22. package/skill/scripts/forge +1 -1
  23. package/src/cli/block.mjs +427 -38
  24. package/src/cli/context.mjs +17 -7
  25. package/src/cli/doctor.mjs +32 -1
  26. package/src/cli/gates.mjs +76 -10
  27. package/src/cli/help.mjs +5 -0
  28. package/src/cli/native-status.mjs +13 -0
  29. package/src/cli/outcome.mjs +87 -0
  30. package/src/cli/pipeline.mjs +61 -0
  31. package/src/cli/proof.mjs +10 -7
  32. package/src/cli/report.mjs +53 -6
  33. package/src/cli/review-file.mjs +278 -41
  34. package/src/cli/run.mjs +3 -2
  35. package/src/cli/status.mjs +43 -9
  36. package/src/cli/sweep.mjs +28 -0
  37. package/src/cli/validate.mjs +6 -0
  38. package/src/config/validate.mjs +83 -1
  39. package/src/context/pack.mjs +53 -3
  40. package/src/doctor/index.mjs +11 -0
  41. package/src/doctor/local.mjs +22 -4
  42. package/src/doctor/worker-probe.mjs +1 -1
  43. package/src/engines/builders/codex.mjs +27 -7
  44. package/src/engines/builders/grok.mjs +11 -5
  45. package/src/engines/codex-home.mjs +18 -15
  46. package/src/engines/sentinel.mjs +11 -0
  47. package/src/gates/detect.mjs +6 -3
  48. package/src/gates/run.mjs +295 -27
  49. package/src/ledger/export.mjs +1 -1
  50. package/src/ledger/report.mjs +143 -5
  51. package/src/outcome/outcome.mjs +108 -0
  52. package/src/pipeline/pipeline.mjs +112 -0
  53. package/src/proof/red-green.mjs +41 -13
  54. package/src/proof/runners.mjs +28 -4
  55. package/src/proof/worktree.mjs +25 -15
  56. package/src/review/agent-waiver.mjs +113 -12
  57. package/src/review/engine.mjs +25 -5
  58. package/src/review/gate-check.mjs +52 -10
  59. package/src/review/lenses/full.md +1 -0
  60. package/src/review/lenses/quick.md +1 -0
  61. package/src/review/model-stats.mjs +125 -0
  62. package/src/review/packet.mjs +88 -9
  63. package/src/review/related.mjs +112 -0
  64. package/src/review/reopen.mjs +42 -16
  65. package/src/review/route.mjs +58 -4
  66. package/src/review/session-retry.mjs +59 -0
  67. package/src/session/facts.mjs +19 -7
  68. package/src/session/plan-check.mjs +4 -1
  69. package/src/session/spawn.mjs +54 -3
  70. package/src/state/block.mjs +139 -49
  71. package/src/state/config-snapshot.mjs +13 -3
  72. package/src/state/run.mjs +3 -2
  73. package/src/util/exec.mjs +48 -31
  74. package/src/util/native.mjs +131 -0
  75. package/src/util/reaper.mjs +238 -8
  76. package/src/worker/engine.mjs +7 -3
  77. package/src/worker/loop.mjs +211 -30
  78. package/src/worker/queue.mjs +107 -24
  79. package/src/worker/watch.mjs +18 -7
@@ -104,9 +104,9 @@ export class FactsError extends Error {
104
104
  const PATTERNS = /** @type {ReadonlyArray<[ClaimKind, RegExp]>} */ ([
105
105
  ['endpoint', /https?:\/\/[^\s`'"<>)\]|]+/g],
106
106
  ['package', /(?<![\w./@-])@[a-z0-9][a-z0-9._-]*\/[a-z0-9][a-z0-9._-]*/g],
107
- ['path', /(?<![\w/.~-])(?:~|\.{1,2})\/[^\s`'"<>)\]|,;]*/g],
107
+ ['path', /(?<![\w/.~-])(?:~|\.{1,2}|[\w.-]+)?\/[^\s`'"<>)\]|,;]*/g],
108
108
  ['flag', /(?<![\w-])--[a-z][a-z0-9-]*/g],
109
- ['version', /(?<![\w.§])\d+\.\d+(?:\.\d+)*/g],
109
+ ['version', /(?<![\w.§])(?<!§ )\d+\.\d+(?:\.\d+)*/g],
110
110
  ['env', /(?<![\w])\$?[A-Z][A-Z0-9]*(?:_[A-Z0-9]+)+(?![\w])/g],
111
111
  ]);
112
112
 
@@ -122,6 +122,7 @@ const trimTail = (token) => token.replace(/[.,:;]+$/, '');
122
122
  * `bin` keys, {@link readProjectBins}); none ⇒ command claims are kept as written.
123
123
  * @property {string} [briefText] - the brief alone (no sources): only IT can name the project CLI
124
124
  * that a command claim gets prefixed with (default: the whole text).
125
+ * @property {string} [projectDir] - project root whose top-level folders admit extensionless paths.
125
126
  */
126
127
 
127
128
  /**
@@ -205,6 +206,8 @@ export function readProjectBins(projectDir) {
205
206
  */
206
207
  export function extractClaims(text, opts = {}) {
207
208
  let masked = String(text);
209
+ const topLevelFolders = new Set(opts.projectDir === undefined ? [] : readdirSync(opts.projectDir, { withFileTypes: true })
210
+ .filter((entry) => entry.isDirectory()).map((entry) => entry.name));
208
211
  /** @type {Array<{index: number, token: string, kind: ClaimKind}>} */
209
212
  const found = [];
210
213
  /** @param {number} start @param {number} length */
@@ -215,7 +218,9 @@ export function extractClaims(text, opts = {}) {
215
218
  const scan = (kind, re) => {
216
219
  for (const m of [...masked.matchAll(re)]) {
217
220
  const token = trimTail(kind === 'env' ? m[0].replace(/^\$/, '') : m[0]);
218
- if (token.length === 0 || (kind === 'path' && /^(~|\.{1,2})\/$/.test(token))) continue;
221
+ if (token.length === 0 || (kind === 'path' && /^(~|\.{1,2})?\/$/.test(token))) continue;
222
+ if (kind === 'path' && !/^(~|\.{1,2})?\//.test(token)
223
+ && !/\.[a-zA-Z0-9][\w-]*$/.test(token) && !topLevelFolders.has(token.split('/')[0])) continue;
219
224
  found.push({ index: /** @type {number} */ (m.index), token, kind });
220
225
  mask(/** @type {number} */ (m.index), m[0].length);
221
226
  }
@@ -778,7 +783,8 @@ function ruleBroken(row, claim, ctx) {
778
783
 
779
784
  /**
780
785
  * Validate the delegate's answer (step 3). Null commands/excerpts are normalized on a copy and
781
- * their matched claims downgraded; every other schema-invalid answer is refused. A row answers
786
+ * their matched claims downgraded; long excerpts are capped without changing the tag. Every other
787
+ * schema-invalid answer is refused. A row answers
782
788
  * claim `F<n>` when its fact_id is `F<n>` and its kind is the claim's kind (the id is the anchor,
783
789
  * the claim text may be reworded); failing that, when its echoed claim ({@link echoedClaim})
784
790
  * equals the claim token and its fact_id is `F<n>` or its kind is the claim's kind. Claims the
@@ -802,9 +808,11 @@ export function validateFacts(answer, claims, opts = {}) {
802
808
  for (const row of normalized.facts) {
803
809
  if (!row || typeof row !== 'object' || Array.isArray(row)) continue;
804
810
  const fields = ['command', 'output_excerpt'].filter((field) => row[field] === null);
805
- if (fields.length === 0) continue;
806
811
  for (const field of fields) row[field] = '';
807
- nullFields.set(row, fields);
812
+ if (fields.length > 0) nullFields.set(row, fields);
813
+ if (typeof row.output_excerpt === 'string' && row.output_excerpt.length > EXCERPT_MAX) {
814
+ row.output_excerpt = `${row.output_excerpt.slice(0, EXCERPT_MAX - 1)}…`;
815
+ }
808
816
  }
809
817
  }
810
818
  if (!validateFile(normalized)) {
@@ -1194,8 +1202,12 @@ export async function buildFacts(opts, deps = {}) {
1194
1202
  if (opts.projectDir !== undefined) {
1195
1203
  try { projectBins = readProjectBins(opts.projectDir); } catch { projectBins = []; }
1196
1204
  }
1197
- const extractOpts = opts.projectDir === undefined ? {} : { projectBins, briefText };
1205
+ const extractOpts = opts.projectDir === undefined ? {} : { projectBins, briefText, projectDir: opts.projectDir };
1198
1206
  const claims = extractClaims([briefText, ...readSourcesText(opts.sources ?? [])].join('\n'), extractOpts);
1207
+ const lines = briefText.split(/\r?\n/).length - Number(briefText.endsWith('\n'));
1208
+ if (lines > 40 && claims.length < 5) {
1209
+ (deps.stderr ?? process.stderr).write(`forge facts: the brief has ${lines} lines but only ${claims.length} claims; the sheet checks almost nothing\n`);
1210
+ }
1199
1211
  const root = opts.runRoot ?? currentRunRoot();
1200
1212
  // B9b.1: a run root inside a git work tree (`tmp.root` configured in the project) cannot hold
1201
1213
  // the snapshot — git and the harness would walk up into the live repo — so the facts area moves
@@ -358,7 +358,10 @@ export function checkPlan(text, opts = {}) {
358
358
  const projectBins = () => (binsCache ??= typeof given === 'function' ? given() : given ?? []);
359
359
  for (const b of blocks) {
360
360
  for (const clause of splitClauses(b.acceptance)) {
361
- const unbacked = extractClaims(clause).filter((c) => !isVerified(c, verified, projectBins));
361
+ // A trailing parenthesized test citation names planned coverage, not a fact about
362
+ // an existing file. Paths in the clause itself still need facts or tolerances.
363
+ const factualClause = clause.replace(/\((?:\s*`(?:\.\/)?(?:test|tests|spec)\/[^`\n]+\.[\w]+`[\s,]*)+\)\s*$/, '');
364
+ const unbacked = extractClaims(factualClause).filter((c) => !isVerified(c, verified, projectBins));
362
365
  if (unbacked.every((c) => tolerances.some((t) => t.includes(c.token) && idRe(b.id).test(t)))) continue;
363
366
  errors.push(`unbackable clause without tolerance: ${b.id} ${clause}`);
364
367
  }
@@ -51,6 +51,7 @@ import { Ajv2020 } from 'ajv/dist/2020.js';
51
51
  import { CODEX_CLOSED_BOOK_REFUSAL, closedBookRefused, isNoToolRole, openBookCodexAllowed } from '../config/closed-book.mjs';
52
52
  import { resolveLevel } from '../config/known-ids.mjs';
53
53
  import { compileSchema } from '../config/schema-compile.mjs';
54
+ import { blockTaskLines, ContextError, readBlockBrief } from '../context/pack.mjs';
54
55
  import { buildArgv } from '../engines/builders/index.mjs';
55
56
  import { removeCodexHome } from '../engines/codex-home.mjs';
56
57
  import { cliNameForProvider } from '../engines/provider-cli.mjs';
@@ -61,6 +62,7 @@ import { budgetUsdOf, checkBudget, estimateSessionUsd, priceSession, reservedUsd
61
62
  import { appendRow, readAllRows } from '../ledger/write.mjs';
62
63
  import { categoryGate, categoryPlan, RECORD_UNKNOWN_MESSAGE } from '../autopilot/limits.mjs';
63
64
  import { StateError } from '../state/paths.mjs';
65
+ import { readRun } from '../state/run.mjs';
64
66
  import { exec } from '../util/exec.mjs';
65
67
  import { isForbidden, mergeForbidden } from '../util/forbidden.mjs';
66
68
  import { semaphore } from '../util/locks.mjs';
@@ -68,6 +70,7 @@ import { readStartTime, registerPid, UNKNOWN_START_TIME } from '../util/reaper.m
68
70
  import { asText, stderrTail } from '../util/diagnostic-tail.mjs';
69
71
  import { redact, writeSafe } from '../util/redact.mjs';
70
72
  import { currentRunRoot, pidsDir, runRoot, tmpBase, untrustedReason } from '../util/tmp.mjs';
73
+ import { assertBlockId, mainRootOf } from '../worker/ticket.mjs';
71
74
 
72
75
  export const ROLES = Object.freeze(['coder', 'reviewer', 'judge', 's2', 'author', 'facts', 'delegate']);
73
76
  export const LEVELS = Object.freeze(['L0', 'L1', 'L2', 'L3']);
@@ -538,6 +541,8 @@ export async function spawnSession(opts, deps = {}) {
538
541
  if (!LEVELS.includes(level)) throw new SessionError('usage', `level must be one of ${LEVELS.join(', ')}`);
539
542
  if (!ROLES.includes(role)) throw new SessionError('usage', `role must be one of ${ROLES.join(', ')}`);
540
543
  if (typeof promptPath !== 'string' || !path.isAbsolute(promptPath)) throw new SessionError('usage', 'promptPath must be an absolute path');
544
+ /** @type {string | null} */
545
+ let coderPrompt = null;
541
546
  const stderr = deps.stderr ?? process.stderr;
542
547
  const runRootDir = opts.runRoot ?? currentRunRoot();
543
548
  if (role === 'facts' && opts.cwd !== undefined) assertFactsCwd(opts.cwd, runRootDir);
@@ -570,6 +575,9 @@ export async function spawnSession(opts, deps = {}) {
570
575
  // in the `finally` below on every other exit path (a throw, a background start)
571
576
  const releaseReservation = gate.release;
572
577
  try {
578
+ // Budget refusals take precedence over task loading, including legacy runs without
579
+ // this block. A failed context load must still release the accepted reservation.
580
+ if (role === 'coder' && opts.block && coderPrompt === null) coderPrompt = await coderPromptFor(opts);
573
581
  const outcome = await runAttempt(step, i + 1 === steps.length, releaseReservation);
574
582
  if (outcome.result) return outcome.result;
575
583
  waitMs = outcome.waitMs;
@@ -602,7 +610,7 @@ export async function spawnSession(opts, deps = {}) {
602
610
  mkdirSync(sessionDir, { recursive: true, mode: 0o700 });
603
611
  let keep = false;
604
612
  try {
605
- const built = buildStep(opts, step, sessionDir, openBook);
613
+ const built = buildStep(opts, step, sessionDir, openBook, coderPrompt);
606
614
  const cli = built.cli;
607
615
  built.argv[0] = deps.bins?.[cli] ?? built.argv[0];
608
616
  if (role === 'coder') {
@@ -659,6 +667,42 @@ export async function spawnSession(opts, deps = {}) {
659
667
  }
660
668
  }
661
669
 
670
+ /**
671
+ * Add the recorded task and completion contract without overwriting the caller's brief.
672
+ * @param {SessionOpts} opts @returns {Promise<string>}
673
+ */
674
+ async function coderPromptFor(opts) {
675
+ let blockId;
676
+ let original;
677
+ try {
678
+ blockId = assertBlockId(opts.block);
679
+ original = readFileSync(opts.promptPath, 'utf8');
680
+ } catch (err) {
681
+ throw new SessionError('usage', err instanceof Error ? err.message : 'the coder brief cannot be read');
682
+ }
683
+ let task = [];
684
+ if (opts.run) {
685
+ let record;
686
+ try {
687
+ record = await readRun(opts.run);
688
+ } catch (err) {
689
+ if (!(err instanceof StateError && err.code === 'no-run')) throw err;
690
+ }
691
+ if (record) {
692
+ const block = record.blocks?.[blockId];
693
+ if (!block) throw new SessionError('usage', `run ${opts.run} has no block ${blockId}`);
694
+ try {
695
+ const repoRoot = await mainRootOf(record.workspace);
696
+ task = blockTaskLines({ brief: readBlockBrief(repoRoot, block), acceptance: block.acceptance }, original);
697
+ } catch (err) {
698
+ if (err instanceof ContextError) throw new SessionError('usage', err.message);
699
+ throw err;
700
+ }
701
+ }
702
+ }
703
+ return `${original.trimEnd()}\n\n${task.length ? `${task.join('\n')}\n\n` : ''}Report the changed files and pass/fail counts. Print TESTS <command> exit=<n> on its own line before the sentinel. On success, print this exact sentinel as your last line:\n===BLOCK ${blockId} COMPLETE===\n`;
704
+ }
705
+
662
706
  /**
663
707
  * B33: a session row's dollars from its tokens and the static price table — `{usd, cost_source:
664
708
  * 'estimated'}`, or `{usd: null, usd_unknown: true}` when the provider/level has no price.
@@ -812,8 +856,9 @@ async function checkGate(opts, deps, writeRow, stderr, budget, estimate) {
812
856
  * @param {{provider: string, model: string, effort?: string, flagFallback?: {provider: string, model: string}}} step
813
857
  * @param {string} sessionDir
814
858
  * @param {boolean} openBook - B32: `review.allow_open_book_codex`, read once by `spawnSession`.
859
+ * @param {string | null} [coderPrompt]
815
860
  */
816
- function buildStep(opts, step, sessionDir, openBook) {
861
+ function buildStep(opts, step, sessionDir, openBook, coderPrompt = null) {
817
862
  const closedBook = opts.role !== 'coder';
818
863
  // B9b.1: the facts delegate may run in the caller's read-only project snapshot; nobody else may
819
864
  const snapshot = opts.role === 'facts' && opts.cwd !== undefined ? realpathSync(path.resolve(opts.cwd)) : null; // validated by assertFactsCwd
@@ -823,11 +868,17 @@ function buildStep(opts, step, sessionDir, openBook) {
823
868
  if (cli === undefined) throw new SessionError('usage', `provider "${step.provider}" has no CLI mapping`);
824
869
  /** @type {Record<string, any>} */
825
870
  const params = { provider: step.provider, role: opts.role, model: step.model, promptPath: opts.promptPath, cwd };
871
+ if (coderPrompt !== null) {
872
+ params.promptPath = path.join(sessionDir, 'coder-brief.md');
873
+ writeFileSync(params.promptPath, coderPrompt, { mode: 0o600 });
874
+ }
826
875
  if (step.effort) params.effort = step.effort;
827
876
  if (step.flagFallback) params.fallback = [step.flagFallback];
828
877
  if (cli === 'claude' && opts.maxBudgetUsd !== undefined) params.maxBudgetUsd = opts.maxBudgetUsd;
829
878
  if (closedBook && opts.systemPromptText !== undefined && cli !== 'codex') params.systemPromptText = opts.systemPromptText;
830
879
  if (cli === 'codex') params.outPath = path.join(sessionDir, 'out.json');
880
+ // The session owns this home: a named run must not use the spawning process's lazy root.
881
+ if (cli === 'codex' && !closedBook) params.runRoot = opts.runRoot ?? currentRunRoot();
831
882
  // B32 opt-in: only a no-tools role needs it; coder and facts build exactly as without it
832
883
  if (cli === 'codex' && openBook && isNoToolRole(opts.role)) params.allowOpenBook = true;
833
884
  // B93: the Codex coder's sandbox profile; no `coder.sandbox` key builds exactly today's argv
@@ -903,7 +954,7 @@ async function runForeground(built, opts, deps) {
903
954
  });
904
955
  } finally {
905
956
  // B4.1: the Codex coder's per-session CODEX_HOME (rules + auth copy) dies with the session.
906
- if (built.env?.CODEX_HOME) removeCodexHome(built.env.CODEX_HOME);
957
+ if (built.env?.CODEX_HOME) removeCodexHome(built.env.CODEX_HOME, opts.runRoot);
907
958
  }
908
959
  const duration_ms = Date.now() - started;
909
960
  /** @type {string | null} */
@@ -24,9 +24,10 @@ 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 { canonicalJSON, verifyRow, loadKey } from './signer.mjs';
27
+ import { canonicalJSON, verifyRow, loadKey, signRow } from './signer.mjs';
28
+ import { readAllRows } from '../ledger/write.mjs';
28
29
  import * as reviewQueue from '../worker/queue.mjs';
29
- import { repoRootOf } from '../worker/ticket.mjs';
30
+ import { assertInsideRoot, contentHash, repoRootOf } from '../worker/ticket.mjs';
30
31
 
31
32
  /** @typedef {import('./run.mjs').WriteRow} WriteRow */
32
33
 
@@ -292,6 +293,16 @@ function activeBlocks(record) {
292
293
  return Object.fromEntries(Object.entries(record.blocks).filter(([, b]) => b.status === 'open'));
293
294
  }
294
295
 
296
+ /** Earlier claims are authoritative only when their run, block and MAC match.
297
+ * @param {Record<string, any>} record @param {string} id @returns {Promise<string[]>}
298
+ */
299
+ async function signedClaims(record, id) {
300
+ const key = await loadKey(record.run_id);
301
+ const rows = await readAllRows(record.project);
302
+ return [...new Set(rows.filter((row) => row.event === 'block.claim' && row.run === record.run_id && row.block === id && verifyRow(row, key).ok)
303
+ .map((row) => assertOwned([row.path])[0]))];
304
+ }
305
+
295
306
  /**
296
307
  * B58: with `review.external.required` set in the run's snapshot, the project's `.code-forge.yml`
297
308
  * is the owner's switch past the local per-file review, so no block may own it. These are the
@@ -362,6 +373,11 @@ function refuseConfigOwner(owned, kinds, guarded) {
362
373
  * B60: `tests` (`block open --tests <paths…>`, the same path rules as owned entries, else
363
374
  * `bad-tests`) is recorded as the entry's `test_files` and on the dispatch row; a gate argv item
364
375
  * `{test_files}` expands to them (`gates/expand.mjs`). Left out, neither carries the key.
376
+ * B148: `after` lists dependencies in this run. Under the same run lock, every dependency must
377
+ * be closed (`after-not-done`); self-dependency is `bad-after`. Unique ids are recorded on the
378
+ * entry and dispatch only when supplied, and a later attempt checks only its own supplied list.
379
+ * B153: `after_outcome` carries the specialist step ids and outcome byte hashes accepted by
380
+ * the CLI against the run's project root; it is recorded on the entry and dispatch when supplied.
365
381
  * B62a: `worktree` (given by `block open` under `proof.isolation: worktree`, `proof/worktree.mjs`;
366
382
  * this module never imports it) plans the block's own worktree, or the reuse of the one an earlier
367
383
  * entry of the same id recorded (`previous`); its `make()` makes or reuses it. Both are called
@@ -376,11 +392,12 @@ function refuseConfigOwner(owned, kinds, guarded) {
376
392
  * @param {{
377
393
  * runId: string, id: string, level: string, owned: string[], acceptance: unknown,
378
394
  * attempt?: number, base?: string, lines?: number, brief?: {path: string, content: Buffer | string},
379
- * kind?: string, cfg?: Record<string, any>, writeRow: WriteRow, now?: Date, tests?: string[],
395
+ * kind?: string, cfg?: Record<string, any>, writeRow: WriteRow, now?: Date, tests?: string[], after?: string[],
396
+ * after_outcome?: {step: string, sha256: string}[],
380
397
  * decision?: {question: 'lane', answer: string, source: 'rules', facts: Record<string, unknown>}, decisionId?: string,
381
- * worktree?: (ctx: {baseSha: string, previous: TreeRecord | null}) => Promise<{path: string, make: () => Promise<OpenTree>}>,
398
+ * worktree?: (ctx: {baseSha: string, baseExplicit: boolean, previous: TreeRecord | null}) => Promise<{path: string, make: () => Promise<OpenTree>}>,
382
399
  * }} opts
383
- * @returns {Promise<{block: Record<string, any>, pointer: string | null, worktreeReused: boolean}>} `worktreeReused`:
400
+ * @returns {Promise<{block: Record<string, any>, pointer: string | null, worktreeReused: boolean, keptClaims: number}>} `worktreeReused`:
384
401
  * the block took an earlier attempt's worktree as it was.
385
402
  */
386
403
  export async function openBlock(opts) {
@@ -391,11 +408,12 @@ export async function openBlock(opts) {
391
408
  if (lines !== null && !(Number.isInteger(lines) && lines > 0)) throw new StateError('bad-lines', 'lines forecast must be a positive integer');
392
409
  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(', ')}`);
393
410
  if (opts.decision && opts.decision.answer !== level) throw new StateError('bad-decision', `the decision answer ${opts.decision.answer} is not the requested level ${level}`);
394
- const owned = assertOwned(opts.owned);
411
+ const declaredOwned = assertOwned(opts.owned);
395
412
  const acceptance = assertAcceptance(opts.acceptance);
396
413
  const humanChecks = acceptance.filter((c) => c.kind === 'human_check').map((c) => c.clause);
397
414
  const tests = opts.tests === undefined ? null : [...assertOwned(opts.tests, { label: 'test files', code: 'bad-tests' })];
398
- const kind = opts.kind === 'light' ? 'light' : blockKind({ owned, declared: opts.kind });
415
+ const after = opts.after === undefined ? null : [...new Set(opts.after)];
416
+ const kind = opts.kind === 'light' ? 'light' : blockKind({ owned: declaredOwned, declared: opts.kind });
399
417
  const dispatched = dispatchLevel({ lane: level, kind, cfg: opts.cfg });
400
418
  const pointer = opts.brief ? briefPointer(opts.brief.path, opts.brief.content) : null;
401
419
 
@@ -420,11 +438,21 @@ export async function openBlock(opts) {
420
438
  /** @type {OpenTree | null} */
421
439
  let made = null;
422
440
  try {
423
- const { block, reused } = await mutateRun(runId, null, writeRow, async (record) => {
441
+ const { block, reused, keptClaims } = await mutateRun(runId, null, writeRow, async (record) => {
424
442
  if (record.blocks[id]?.status === 'open') throw new StateError('already-open', `block ${id} is already open`);
425
443
  if (record.blocks[id]?.status === 'awaiting_human_check') throw new StateError('already-open', `block ${id} is awaiting a human check`);
444
+ if (after !== null) {
445
+ if (after.includes(id)) throw new StateError('bad-after', `block ${id} cannot name itself in --after`);
446
+ const blocking = after.filter((dependency) => record.blocks[dependency]?.status !== 'closed');
447
+ if (blocking.length > 0) {
448
+ const statuses = blocking.map((dependency) => `${dependency} (${record.blocks[dependency]?.status ?? 'not in run'})`);
449
+ throw new StateError('after-not-done', `block ${id}: --after dependencies not closed: ${statuses.join(', ')}`);
450
+ }
451
+ }
426
452
  // B57: what each exact owned entry is on disk, under the lock (as `block claim`) — a regular
427
453
  // file owns only itself, here and in every later check
454
+ const claims = attempt > 1 ? await signedClaims(record, id) : [];
455
+ const owned = [...new Set([...declaredOwned, ...claims])];
428
456
  const kinds = ownedKinds(record.workspace, owned);
429
457
  refuseConfigOwner(owned, kinds, guarded);
430
458
  const prior = previousTree(record);
@@ -433,7 +461,7 @@ export async function openBlock(opts) {
433
461
  const branch = await git(['git', 'symbolic-ref', '--quiet', '--short', 'HEAD'], record.workspace, [0, 1]);
434
462
  const baseBranch = branch.code === 0 ? branch.stdout.trim() : null;
435
463
  // B62a: the tree is planned (read-only checks) before the overlap check, which needs its path
436
- const planned = opts.worktree ? await opts.worktree({ baseSha, previous: prior }) : null;
464
+ const planned = opts.worktree ? await opts.worktree({ baseSha, baseExplicit: opts.base !== undefined, previous: prior }) : null;
437
465
  const self = { owned_files: owned, owned_kinds: kinds, worktree: planned ? { path: planned.path } : null };
438
466
  for (const [otherId, other] of Object.entries(activeBlocks(record))) {
439
467
  const pair = blocksOverlap(self, other);
@@ -443,21 +471,23 @@ export async function openBlock(opts) {
443
471
  // passed; a tree made here is rolled back below if the rows or the save then fail
444
472
  made = planned ? await planned.make() : null;
445
473
  const tree = made;
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 } : {}) };
474
+ const outcomeDependencies = opts.after_outcome === undefined ? {} : { after_outcome: opts.after_outcome };
475
+ 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 } : {}), ...(after !== null ? { after } : {}), ...outcomeDependencies, ...(tree ? { worktree: tree.worktree } : {}) };
447
476
  if (humanChecks.length > 0) Object.assign(entry, { human_checks: humanChecks });
477
+ if (opts.brief) Object.assign(entry, { brief: { path: opts.brief.path } });
448
478
  record.blocks[id] = entry;
449
479
  const floor = dispatched.trigger === 'docs_floor' ? { requested_level: level, trigger: 'docs_floor' } : {};
450
480
  const created = tree?.created ? [{ event: 'worktree.created', block: id, path: tree.worktree.path, branch: tree.worktree.branch, base: tree.worktree.base_sha }] : [];
451
481
  return {
452
- result: { block: entry, reused: tree !== null && !tree.created },
482
+ result: { block: entry, reused: tree !== null && !tree.created, keptClaims: claims.length },
453
483
  rows: [
454
484
  ...(opts.decision ? [{ ...opts.decision, event: 'decision', decision_id: opts.decisionId ?? randomUUID(), block: id }] : []),
455
485
  ...created,
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 } : {}) },
486
+ { 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 } : {}), ...(after !== null ? { after } : {}), ...outcomeDependencies },
457
487
  ],
458
488
  };
459
489
  });
460
- return { block, pointer, worktreeReused: reused };
490
+ return { block, pointer, worktreeReused: reused, keptClaims };
461
491
  } catch (err) {
462
492
  // B62a: nothing was recorded, so a tree this open made must not outlive it
463
493
  if (made?.created && made.rollback) {
@@ -475,9 +505,12 @@ export async function openBlock(opts) {
475
505
 
476
506
  /** @param {{runId: string, id: string, writeRow: WriteRow}} opts */
477
507
  export async function attemptBlock({ runId, id, writeRow }) {
478
- return mutateRun(runId, id, writeRow, (_record, block) => {
508
+ return mutateRun(runId, id, writeRow, async (record, block) => {
509
+ const claims = await signedClaims(record, id);
510
+ block.owned_files = [...new Set([...block.owned_files, ...claims])];
511
+ block.owned_kinds = { ...ownedKinds(record.workspace, claims), ...block.owned_kinds };
479
512
  block.attempt += 1;
480
- return { result: block.attempt, rows: [{ event: 'block.attempt', block: id, attempt: block.attempt }] };
513
+ return { result: { attempt: block.attempt, keptClaims: claims.length }, rows: [{ event: 'block.attempt', block: id, attempt: block.attempt }] };
481
514
  });
482
515
  }
483
516
 
@@ -486,18 +519,19 @@ export async function attemptBlock({ runId, id, writeRow }) {
486
519
  * in between touched a file this block owns (that is a conflict ⇒ stop). Rename detection is OFF
487
520
  * (`--no-renames`): a rename lists both its old and its new path, so moving an owned file away
488
521
  * is a conflict too. Never silent. Owned means `ownsFile` (B57: a directory entry owns the files
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.
492
- * @param {{runId: string, id: string, head?: string, writeRow: WriteRow}} opts
493
- * @returns {Promise<{old_base: string, new_base: string}>}
522
+ * below it). Worktree blocks rebase their branch onto the recorded base branch. Open blocks
523
+ * refuse upstream commits touching owned files; closed blocks retain every changed owned blob.
524
+ * A refusal restores the head at entry. A rebase check keeps the candidate content available
525
+ * to the worker, with a signed checkpoint; the recorded base moves only after clearance.
526
+ * @param {{runId: string, id: string, head?: string, writeRow: WriteRow, acceptChanged?: string[], reason?: string, rebaseCheck?: boolean}} opts
527
+ * @returns {Promise<{old_base: string, new_base: string, files?: ReturnType<typeof reviewQueue.enqueue>[], pending?: boolean}>}
494
528
  */
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 });
529
+ export async function rebaseBlock({ runId, id, head = 'HEAD', writeRow, acceptChanged = [], reason, rebaseCheck = false }) {
530
+ if (acceptChanged.length && (typeof reason !== 'string' || !reason.trim())) throw new StateError('usage', '--accept-changed needs a nonblank --reason');
531
+ const block = (await readRun(runId)).blocks[id];
532
+ if (block?.worktree || ['closed', 'awaiting_human_check'].includes(block?.status)) return rebaseWorktreeBlock({ runId, id, writeRow, acceptChanged, reason, rebaseCheck });
533
+ if (rebaseCheck || acceptChanged.length) throw new StateError('usage', 'rebase clearance requires a worktree block');
497
534
  return mutateRun(runId, id, writeRow, async (record, block) => {
498
- // B62a: a worktree block's base is the commit its tree started from; moving it would leave the
499
- // tree on its old base, so the rebase is refused
500
- if (block.worktree) throw new StateError('worktree-rebase', `block ${id} has its own worktree ${block.worktree.path} on branch ${block.worktree.branch}; its base is the base of that tree, so block rebase does not apply — work on the branch in the tree instead`);
501
535
  const cwd = record.workspace;
502
536
  const newBase = await resolveCommit(head, cwd);
503
537
  const ancestor = await git(isAncestorArgv(block.base_sha, newBase), cwd, [0, 1]);
@@ -507,7 +541,7 @@ export async function rebaseBlock({ runId, id, head = 'HEAD', writeRow }) {
507
541
  if (conflicts.length > 0) throw new StateError('owned-conflict', `block ${id}: commits since its base changed owned files: ${conflicts.join(', ')}`);
508
542
  const moved = { old_base: block.base_sha, new_base: newBase };
509
543
  block.base_sha = newBase;
510
- return { result: moved, rows: [{ event: 'block.rebase', block: id, ...moved }] };
544
+ return { result: moved, rows: [{ event: 'block.rebase', block: id, ...moved, changed_reviewed: [], cleared: [] }] };
511
545
  });
512
546
  }
513
547
 
@@ -530,44 +564,91 @@ async function committedBlobs(cwd, ref, files) {
530
564
  }
531
565
 
532
566
  /**
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}>}
567
+ * Recheck worktree block status under the lock, restore the branch on failure (including a
568
+ * failed signed write), and retain its open/closed/awaiting status.
569
+ * @param {{runId: string, id: string, writeRow: WriteRow, acceptChanged: string[], reason?: string, rebaseCheck: boolean}} opts
570
+ * @returns {Promise<{old_base: string, new_base: string, files?: ReturnType<typeof reviewQueue.enqueue>[], pending?: boolean}>}
537
571
  */
538
- async function rebaseClosedBlock({ runId, id, writeRow }) {
572
+ async function rebaseWorktreeBlock({ runId, id, writeRow, acceptChanged, reason, rebaseCheck }) {
539
573
  return withRunLock(runId, async () => {
540
574
  const record = await readRun(runId);
541
575
  if (record.status !== 'active') throw new StateError('run-ended', `run ${runId} has ended`);
542
576
  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}`);
577
+ if (!['open', 'closed', 'awaiting_human_check'].includes(block?.status) || !block.worktree) throw new StateError('no-block', `block ${id} is not open in run ${runId}`);
578
+ const closed = block.status !== 'open';
544
579
  if (!block.base_branch) throw new StateError('rebase-no-base', `block ${id} has no recorded base branch`);
545
580
  const tree = block.worktree.path;
546
581
  const branch = await git(['git', 'symbolic-ref', '--quiet', 'HEAD'], tree, [0, 1]);
547
582
  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
583
  // A reset must never discard edits made after close, or abort an earlier rebase.
549
584
  const status = await git(['git', 'status', '--porcelain'], tree);
550
- if (status.stdout.length > 0) throw new StateError('rebase-conflict', 'rebase-conflict');
585
+ if (status.stdout.length > 0) throw new StateError(closed ? 'rebase-conflict' : 'rebase-dirty', closed ? 'rebase-conflict' : `rebase-dirty: block ${id}: commit or clean first`);
551
586
  const oldHead = await resolveCommit('HEAD', tree);
587
+ const key = await loadKey(runId);
588
+ const pending = block.rebase_check;
589
+ if (pending && (!verifyRow(pending, key).ok || pending.run !== runId || pending.block !== id || pending.old_base !== block.base_sha)) {
590
+ throw new StateError('rebase-check-invalid', 'the pending rebase checkpoint is invalid');
591
+ }
592
+ if (pending && (await git(isAncestorArgv(pending.head, oldHead), tree, [0, 1])).code !== 0) {
593
+ throw new StateError('rebase-check-invalid', 'the worktree no longer contains the pending rebase');
594
+ }
595
+ const reviewedHead = pending?.old_head ?? oldHead;
552
596
  const newBase = await resolveCommit(`refs/heads/${block.base_branch}`, record.workspace);
553
597
  const top = await repoTop(record.workspace);
554
598
  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 && (
599
+ const owns = (/** @type {string} */ file) => (
557
600
  ownsFile(block.owned_files, file, block.owned_kinds) ||
558
601
  (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);
602
+ );
603
+ let files = [];
604
+ if (closed) {
605
+ const diff = await git(['git', 'diff', '--no-renames', '--name-only', '-z', block.base_sha, reviewedHead, '--'], tree);
606
+ files = diff.stdout.split('\0').filter((file) => file.length > 0 && owns(file)).sort();
607
+ } else {
608
+ // Inspect every upstream commit, including changes later reverted and both rename paths.
609
+ const upstream = await git(['git', 'log', '--format=', '--no-renames', '--name-only', '-z', '-m', `${block.base_sha}..${newBase}`, '--'], tree);
610
+ const conflicts = [...new Set(upstream.stdout.split('\0').filter((file) => file.length > 0 && owns(file)))].sort();
611
+ if (conflicts.length > 0) throw new StateError('owned-conflict', `block ${id}: commits since its base changed owned files: ${conflicts.join(', ')}`);
612
+ }
613
+ const before = closed ? await committedBlobs(tree, reviewedHead, files) : null;
561
614
  const moved = { old_base: block.base_sha, new_base: newBase };
562
615
  try {
563
616
  const rebased = await exec(['git', '-C', tree, 'rebase', newBase], { cwd: record.workspace, env: gitEnv(), timeoutMs: 30000 });
564
617
  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(', ')}`);
618
+ const after = before === null ? null : await committedBlobs(tree, 'HEAD', files);
619
+ const changed = after === null ? [] : files.filter((file) => before.get(file) !== after.get(file));
620
+ const repoRoot = await repoRootOf(record.workspace);
621
+ if (rebaseCheck) {
622
+ // The worker reviews the authoritative worktree, so retain this candidate while leaving
623
+ // both bases untouched. Sign the original head so a later retry cannot forget changes.
624
+ block.rebase_check = signRow({ event: 'block.rebase_check', run: runId, block: id, ...moved, old_head: reviewedHead, head: await resolveCommit('HEAD', tree), started_at: pending?.started_at ?? new Date().toISOString() }, key);
625
+ const queued = changed.map((file) => reviewQueue.enqueue({ repoRoot, run: runId, block: id, file, tree, after: block.rebase_check.started_at }));
626
+ await saveRun(record);
627
+ return { ...moved, files: queued, pending: true };
628
+ }
629
+ const invalidAccepted = acceptChanged.filter((file) => !changed.includes(file));
630
+ if (invalidAccepted.length) throw new StateError('usage', `--accept-changed names files not changed by this rebase: ${invalidAccepted.join(', ')}`);
631
+ const rows = pending ? (await readAllRows(record.project)).filter((row) => row.run === runId && row.block === id && verifyRow(row, key).ok && Date.parse(row.ts) >= Date.parse(pending.started_at)) : [];
632
+ /** @type {Array<{file: string, via: string, ticket?: string, reason?: string}>} */
633
+ const cleared = [];
634
+ for (const file of changed) {
635
+ if (acceptChanged.includes(file)) {
636
+ cleared.push({ file, via: 'human', reason });
637
+ continue;
638
+ }
639
+ const hash = contentHash(tree, file);
640
+ const ticket = reviewQueue.resolveTicket({ repoRoot, run: runId, block: id, file, tree, after: pending?.started_at }).ticket;
641
+ const relevant = rows.filter((row) => row.file === file && row.content_hash === hash);
642
+ const approval = relevant.reduce((last, row, index) => row.event === 'review.approved' && (row.ticket === undefined || row.ticket === ticket) ? index : last, -1);
643
+ const denied = relevant.slice(approval + 1).some((row) => row.event === 'review.cap' || (row.event === 'review.result' && row.approved === false) || (row.event === 'review.round' && row.open_after > 0));
644
+ if (approval >= 0 && !denied) cleared.push({ file, via: 'review', ticket });
645
+ }
646
+ const uncleared = changed.filter((file) => !cleared.some((entry) => entry.file === file));
647
+ if (uncleared.length) throw new StateError('rebase-changed-reviewed', `rebase-changed-reviewed ${uncleared.join(', ')}\nRe-review: code-forge review-file --rebase-check --block ${id} --run ${runId}\nHuman accept (repeat --accept-changed for each file): code-forge block rebase ${id} --run ${runId} --accept-changed <file> --reason <text>`);
568
648
  block.base_sha = newBase;
569
649
  block.worktree.base_sha = newBase;
570
- await writeSigned(runId, writeRow, { event: 'block.rebase', block: id, ...moved, closed: true });
650
+ delete block.rebase_check;
651
+ await writeSigned(runId, writeRow, { event: 'block.rebase', block: id, ...moved, changed_reviewed: changed, cleared, ...(closed ? { closed: true } : { worktree: true }) });
571
652
  await saveRun(record);
572
653
  return moved;
573
654
  } catch (err) {
@@ -588,25 +669,33 @@ async function rebaseClosedBlock({ runId, id, writeRow }) {
588
669
  * B58: with `review.external.required` set, a path that owns the project's config is refused
589
670
  * (`config-owned`), as at `block open`. B62a: two blocks that each have their own worktree may
590
671
  * own the same path (`blocksOverlap`).
591
- * @param {{runId: string, id: string, file: string, writeRow: WriteRow}} opts
672
+ * @param {{runId: string, id: string, file: string, writeRow: WriteRow, auto?: boolean}} opts
592
673
  */
593
- export async function claimPath({ runId, id, file, writeRow }) {
674
+ export async function claimPath({ runId, id, file, writeRow, auto = false }) {
594
675
  assertOwned([file]);
595
676
  if (!isExactEntry(file)) throw new StateError('bad-owned', `block claim takes one exact path, not a glob (${JSON.stringify(file)})`);
596
677
  const guarded = await guardedConfigPaths(await readRun(runId));
597
- await mutateRun(runId, id, writeRow, (record, block) => {
598
- const kinds = { [file]: pathKind(record.workspace, file) };
678
+ await mutateRun(runId, id, writeRow, async (record, block) => {
679
+ const root = auto ? await repoTop(block.worktree?.path ?? record.workspace) : record.workspace;
680
+ if (auto) {
681
+ if (file.split('/').some((part) => ['.git', '.code-forge'].includes(part.toLowerCase()))) throw new StateError('unowned_change', `unowned_change ${file}: cannot auto-claim repository state`);
682
+ try { assertInsideRoot(root, file); }
683
+ catch { throw new StateError('unowned_change', `unowned_change ${file}: cannot auto-claim a path outside the repository or a symlink`); }
684
+ }
685
+ const kinds = { [file]: pathKind(root, file) };
599
686
  refuseConfigOwner([file], kinds, guarded);
687
+ const prefix = auto ? path.relative(await repoTop(record.workspace), realpathSync(record.workspace)).split(path.sep).join('/') : '';
600
688
  for (const [otherId, other] of Object.entries(activeBlocks(record))) {
601
- const pair = otherId === id ? null : blocksOverlap({ owned_files: [file], owned_kinds: kinds, worktree: block.worktree ?? null }, other);
602
- if (pair) throw new StateError('overlap', `${file} overlaps ${pair[1]} owned by open block ${otherId}`);
689
+ const pair = otherId === id ? null : blocksOverlap({ owned_files: [file], owned_kinds: kinds, worktree: auto ? null : block.worktree ?? null }, other);
690
+ const relativeOwner = auto && otherId !== id && prefix !== '' && file.startsWith(`${prefix}/`) && ownsFile(other.owned_files, file.slice(prefix.length + 1), other.owned_kinds);
691
+ if (pair || relativeOwner) throw new StateError(auto ? 'unowned_change' : 'overlap', `${auto ? 'unowned_change ' : ''}${file} overlaps ${pair?.[1] ?? file} owned by open block ${otherId}`);
603
692
  }
604
693
  if (!block.owned_files.includes(file)) {
605
694
  block.owned_files.push(file);
606
695
  block.owned_kinds = { ...block.owned_kinds, ...kinds };
607
696
  }
608
697
  record.orphans = record.orphans.filter((/** @type {string} */ o) => !ownsFile([file], o, kinds));
609
- return { result: undefined, rows: [{ event: 'block.claim', block: id, path: file }] };
698
+ return { result: undefined, rows: [{ event: 'block.claim', block: id, path: file, ...(auto ? { auto: true, reason: 'auto-claim: changed outside owned files' } : {}) }] };
610
699
  });
611
700
  }
612
701
 
@@ -679,9 +768,10 @@ export async function closeBlock({ runId, id, rows, writeRow, livePid, probe, ex
679
768
 
680
769
  let pin = await checkWorkerPin(record, { livePid, probe });
681
770
  const pins = record.workers ?? (record.worker ? [record.worker] : []);
771
+ const reviewPins = [...pins, ...(record.worker_history ?? [])];
682
772
  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
773
  // 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))) {
774
+ if (counted.some((row) => row.worker !== undefined && !reviewPins.some((p) => p.pid === row.worker?.pid && p.started_at === row.worker?.started_at))) {
685
775
  pin = { ok: false, event: 'worker.replaced', reason: 'a counted review result does not name a pinned worker' };
686
776
  }
687
777
  if (pin.ok) {
@@ -45,7 +45,7 @@ export const IMMUTABLE_KEYS = Object.freeze({
45
45
  // B64b: the block-end hook runs a command on the owner's machine at every block end, so only
46
46
  // the owner sets it, before the run starts
47
47
  'hooks.on_block_end': 'it runs a command on the owner\'s machine at every block end; fixed by the owner before the run starts',
48
- // B87: the runner argv proves the coder's own work, so only the owner sets it, before the run starts
48
+ // B171: existing runner entries stay fixed; immutableChanges permits append-only additions.
49
49
  'proof.runners': 'the commands that prove a block are fixed by the owner before the run starts',
50
50
  // B93: the coder sandbox profile widens what the coder may write, so only the owner sets it,
51
51
  // before the run starts (a coder can never own `.code-forge.yml`)
@@ -138,7 +138,17 @@ function valueAt(cfg, dotted) {
138
138
  * @returns {string[]}
139
139
  */
140
140
  export function immutableChanges(before, after) {
141
- return Object.keys(IMMUTABLE_KEYS).filter((k) => canonicalJSON(valueAt(before, k) ?? null) !== canonicalJSON(valueAt(after, k) ?? null));
141
+ return Object.keys(IMMUTABLE_KEYS).filter((k) => {
142
+ const oldValue = valueAt(before, k);
143
+ const newValue = valueAt(after, k);
144
+ // Additions go at the end: prepending could replace the first match of an existing runner.
145
+ if (k === 'proof.runners' && Array.isArray(newValue)) {
146
+ const oldRunners = oldValue ?? [];
147
+ if (Array.isArray(oldRunners) && newValue.length >= oldRunners.length
148
+ && oldRunners.every((entry, i) => canonicalJSON(entry) === canonicalJSON(newValue[i]))) return false;
149
+ }
150
+ return canonicalJSON(oldValue ?? null) !== canonicalJSON(newValue ?? null);
151
+ });
142
152
  }
143
153
 
144
154
  /**
@@ -243,7 +253,7 @@ export function blockEndHook(record) {
243
253
  }
244
254
 
245
255
  /**
246
- * B87: `proof.runners` of the run-start config snapshot (never the live config), as a deep copy
256
+ * B87: `proof.runners` of the snapshot in force (never the live config), as a deep copy
247
257
  * (entries and argv arrays), or null when the key is absent or the record is from before
248
258
  * snapshots. `proof/runners.mjs` checks each entry again before it runs one (fail closed).
249
259
  * @param {Record<string, any>} record