@codedology/code-forge 0.7.7 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (59) hide show
  1. package/CHANGELOG.md +41 -0
  2. package/README.md +50 -2
  3. package/docs/README.md +2 -1
  4. package/docs/reference/blocks.json +157 -1
  5. package/docs/reference/config.md +26 -8
  6. package/package.json +1 -1
  7. package/schema/code-forge.schema.json +81 -4
  8. package/skill/SKILL.md +6 -6
  9. package/skill/references/code.md +33 -4
  10. package/skill/references/ledger.md +8 -4
  11. package/skill/references/proof.md +44 -3
  12. package/skill/references/review.md +4 -2
  13. package/skill/references/security.md +1 -1
  14. package/skill/scripts/forge +1 -1
  15. package/skill/templates/coder-brief.md +18 -1
  16. package/src/autopilot/binnacle.mjs +21 -3
  17. package/src/cli/block.mjs +292 -33
  18. package/src/cli/context.mjs +175 -0
  19. package/src/cli/gates.mjs +125 -15
  20. package/src/cli/ledger.mjs +46 -2
  21. package/src/cli/proof.mjs +62 -16
  22. package/src/cli/report.mjs +7 -4
  23. package/src/cli/review-file.mjs +61 -6
  24. package/src/cli/status.mjs +212 -0
  25. package/src/config/validate.mjs +45 -11
  26. package/src/context/pack.mjs +319 -0
  27. package/src/doctor/local.mjs +2 -1
  28. package/src/engines/sentinel.mjs +27 -6
  29. package/src/gates/detect.mjs +21 -8
  30. package/src/gates/expand.mjs +202 -0
  31. package/src/gates/owned-count.mjs +66 -0
  32. package/src/gates/run.mjs +49 -12
  33. package/src/install/wizard/answers.mjs +34 -5
  34. package/src/install/wizard/flags.mjs +8 -1
  35. package/src/install/wizard/project-file.mjs +2 -0
  36. package/src/install/wizard/run.mjs +6 -0
  37. package/src/install/wizard/steps.mjs +24 -4
  38. package/src/install/wizard/summary.mjs +13 -5
  39. package/src/ledger/export.mjs +1 -1
  40. package/src/ledger/outside-review.mjs +164 -0
  41. package/src/ledger/report.mjs +17 -5
  42. package/src/proof/export.mjs +93 -10
  43. package/src/proof/lock.mjs +28 -5
  44. package/src/proof/worktree.mjs +482 -0
  45. package/src/review/gate-check.mjs +9 -5
  46. package/src/review/open-findings.mjs +59 -0
  47. package/src/state/block-end.mjs +290 -0
  48. package/src/state/block.mjs +103 -39
  49. package/src/state/brief-status.mjs +131 -0
  50. package/src/state/config-snapshot.mjs +51 -0
  51. package/src/state/registry.mjs +35 -4
  52. package/src/state/run.mjs +10 -1
  53. package/src/util/breadcrumbs.mjs +1 -0
  54. package/src/util/forbidden.mjs +36 -1
  55. package/src/util/gate-token.mjs +8 -0
  56. package/src/worker/engine.mjs +157 -17
  57. package/src/worker/loop.mjs +26 -7
  58. package/src/worker/queue.mjs +36 -10
  59. package/src/worker/ticket.mjs +46 -6
@@ -34,6 +34,16 @@ export const IMMUTABLE_KEYS = Object.freeze({
34
34
  // B58: the owner's switch past the local per-file review is set before the run starts or not at
35
35
  // all, so nobody turns it on (or off) mid-run
36
36
  'review.external': 'the outside reviewer is fixed by the owner before the run starts',
37
+ // B62a: every block of the run is isolated the same way — only two blocks that each have their
38
+ // own worktree may share owned paths — so neither key changes mid-run
39
+ 'proof.isolation': 'every block of the run is isolated the same way',
40
+ 'proof.worktree': 'the worktrees and branches of the open blocks were made with it',
41
+ // B63b: it tightens `block close` (the coder's green test run), so it is authority like
42
+ // `review.external`; the rest of `context` stays live
43
+ 'context.require_coder_tests': 'whether close needs the coder\'s green test run is fixed by the owner before the run starts',
44
+ // B64b: the block-end hook runs a command on the owner's machine at every block end, so only
45
+ // the owner sets it, before the run starts
46
+ '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',
37
47
  });
38
48
 
39
49
  /** @param {unknown} v @returns {boolean} what `JSON.stringify` drops from an object. */
@@ -184,3 +194,44 @@ export function externalReviewer(record) {
184
194
  const name = typeof external.name === 'string' ? external.name.trim() : '';
185
195
  return name === '' ? null : name; // trimmed: no row ever carries padding
186
196
  }
197
+
198
+ /**
199
+ * B63b: whether `block close` needs the coder's `TESTS <command> exit=0` line — true only when the
200
+ * snapshot in force sets `context.require_coder_tests: true` (false for the key absent or false,
201
+ * or a record from before snapshots). The key is immutable ({@link IMMUTABLE_KEYS}), so this is
202
+ * the value `run start` stored; the live config is never read.
203
+ * @param {Record<string, any>} record
204
+ * @returns {boolean}
205
+ * @throws {StateError} `config-snapshot` when the snapshot in force does not match its hash.
206
+ */
207
+ export function requireCoderTests(record) {
208
+ const context = configInForce(record)?.context;
209
+ return isMapping(context) && context.require_coder_tests === true;
210
+ }
211
+
212
+ /** The default `hooks.on_block_end.timeout_s`. */
213
+ export const HOOK_TIMEOUT_DEFAULT_S = 60;
214
+ /** The schema's bounds of `hooks.on_block_end.timeout_s`. */
215
+ export const HOOK_TIMEOUT_MIN_S = 1;
216
+ export const HOOK_TIMEOUT_MAX_S = 600;
217
+
218
+ /**
219
+ * `hooks.on_block_end` of the run-start config snapshot (never the live config), or null when it
220
+ * is absent or malformed (the schema refuses a malformed one at `run start`).
221
+ * @param {Record<string, any>} record
222
+ * @returns {{argv: string[], timeoutS: number} | null}
223
+ */
224
+ export function blockEndHook(record) {
225
+ let hook;
226
+ try {
227
+ hook = configInForce(record)?.hooks?.on_block_end;
228
+ } catch {
229
+ return null;
230
+ }
231
+ if (hook === null || typeof hook !== 'object') return null;
232
+ const { argv, timeout_s: timeout } = hook;
233
+ if (!Array.isArray(argv) || argv.length === 0 || !argv.every((a) => typeof a === 'string' && a.length > 0)) return null;
234
+ const timeoutS = timeout === undefined ? HOOK_TIMEOUT_DEFAULT_S : timeout;
235
+ if (!Number.isInteger(timeoutS) || timeoutS < HOOK_TIMEOUT_MIN_S || timeoutS > HOOK_TIMEOUT_MAX_S) return null;
236
+ return { argv: [...argv], timeoutS };
237
+ }
@@ -34,6 +34,10 @@
34
34
  * vs `src/**` and vs `src/feature/*.swift` overlap, vs `src/*.mjs` and `src/featureX/*` do not);
35
35
  * a `file` entry against a glob overlaps only when the glob matches it (`src/README` vs
36
36
  * `src/**\/*.mjs` does not).
37
+ *
38
+ * B62a — between two OPEN BLOCKS the rule is `blocksOverlap`: two blocks that each have their own
39
+ * worktree (`proof.isolation: worktree`) may own the same path; any pair with a shared-tree block
40
+ * in it is checked with `findOverlap` exactly as above.
37
41
  */
38
42
 
39
43
  import { StateError } from './paths.mjs';
@@ -99,21 +103,24 @@ export function expandBraces(pattern) {
99
103
 
100
104
  /**
101
105
  * @param {unknown} owned
106
+ * @param {{label?: string, code?: string}} [as] - B60: `block open --tests` checks its test files by
107
+ * the same rules, named `test files` with the code `bad-tests`.
102
108
  * @returns {string[]}
103
109
  * An EXACT entry (no `*`, `?` or `{`) may contain `[ ] ( ) ! + @ }` — real routes such as
104
110
  * `pages/[id].vue`, `app/(group)/page.tsx`, `@types/x.d.ts` — and is matched literally by
105
111
  * string equality. A GLOB entry may not: there those characters would be character-class or
106
112
  * extglob syntax this matcher does not implement, so it is refused with that reason rather than
107
113
  * silently read one way by us and another way by git or a shell.
108
- * @throws {StateError} `bad-owned` — empty, absolute, `.`/`..`/empty segments (checked on every
114
+ * @throws {StateError} with code `as.code` (default `bad-owned`) and a message that starts with
115
+ * `as.label` (default `owned files`) — empty, absolute, `.`/`..`/empty segments (checked on every
109
116
  * brace alternative), backslashes, unbalanced braces, or unsupported glob syntax in a glob.
110
117
  */
111
- export function assertOwned(owned) {
112
- if (!Array.isArray(owned) || owned.length === 0) throw new StateError('bad-owned', 'owned files: at least one path is required');
118
+ export function assertOwned(owned, { label = 'owned files', code = 'bad-owned' } = {}) {
119
+ if (!Array.isArray(owned) || owned.length === 0) throw new StateError(code, `${label}: at least one path is required`);
113
120
  for (const entry of owned) {
114
121
  /** @param {string} why */
115
122
  const refuse = (why) => {
116
- throw new StateError('bad-owned', `owned files: invalid path ${JSON.stringify(entry)}: ${why}`);
123
+ throw new StateError(code, `${label}: invalid path ${JSON.stringify(entry)}: ${why}`);
117
124
  };
118
125
  if (typeof entry !== 'string' || entry.length === 0) refuse('must be a non-empty string');
119
126
  if (entry.includes('\\')) refuse('backslashes are not allowed (POSIX paths only)');
@@ -240,6 +247,30 @@ export function findOverlap(ownedA, ownedB, { kindsA, kindsB } = {}) {
240
247
  return null;
241
248
  }
242
249
 
250
+ /**
251
+ * One open block as the overlap rule sees it: its owned entries, their kinds (B57) and its own
252
+ * worktree, if any (B62a). `worktree` is the run record's `{path, branch, base_sha}` (or, for a
253
+ * block being opened, `{path}` of the tree it is about to get); `null` or absent means it works in
254
+ * the shared tree (export or lock isolation). Every caller passes this one shape.
255
+ * @typedef {{owned_files: ReadonlyArray<string>, owned_kinds?: OwnedKinds, worktree?: {path: string} | null}} OverlapBlock
256
+ */
257
+
258
+ /**
259
+ * B62a: the owned-files overlap rule between two open blocks. Two blocks that EACH have their own
260
+ * worktree, at different paths, may own the same path — each edits its own copy on its own branch,
261
+ * and nothing is merged by code-forge — so the pair never overlaps. Two blocks on the SAME tree
262
+ * path edit one tree, and a block without a worktree (export or lock isolation) edits the shared
263
+ * tree: both are checked exactly as before B62a ({@link findOverlap}, B57's kinds included).
264
+ * @param {OverlapBlock} a @param {OverlapBlock} b
265
+ * @returns {[string, string] | null} the first overlapping pair (a's entry, b's entry), or null.
266
+ */
267
+ export function blocksOverlap(a, b) {
268
+ const treeA = a.worktree ?? null;
269
+ const treeB = b.worktree ?? null;
270
+ if (treeA !== null && treeB !== null && treeA.path !== treeB.path) return null;
271
+ return findOverlap(a.owned_files, b.owned_files, { kindsA: a.owned_kinds, kindsB: b.owned_kinds });
272
+ }
273
+
243
274
  /**
244
275
  * Does `owned` own repo-relative `file`? A directory-like exact entry owns its path and every path
245
276
  * below it, a `file` one (per `kinds`) only itself (B57); a glob entry owns what it matches. A
package/src/state/run.mjs CHANGED
@@ -217,7 +217,9 @@ export async function startRun(opts) {
217
217
  * Fixed keys ({@link IMMUTABLE_KEYS}): every one that changed is refused at once
218
218
  * (`immutable-key`, nothing written). A record from before snapshots (no `config`) is checked
219
219
  * against what it does store: `engine` and the ledger slug (`effectiveSlug`, computed by the
220
- * caller the same way `run start` did), and B58's `review.external.required` must not turn true;
220
+ * caller the same way `run start` did), B58's `review.external.required` must not turn true,
221
+ * B62a's `proof.isolation` must not turn to `worktree`, and B63b's `context.require_coder_tests`
222
+ * must not turn true;
221
223
  * it then gets its first snapshot, `changed_keys: null`, `old_hash: null`.
222
224
  *
223
225
  * No change (same hash) ⇒ nothing written, no row. Otherwise the record is saved FIRST (it is the
@@ -260,6 +262,13 @@ export async function reloadRun({ runId, config, readPending = () => [], effecti
260
262
  // B58: a run started without the key has none to keep — turning it on now is a change; an
261
263
  // entry that leaves it off (`required` false or absent, as the schema default) is not
262
264
  if (config.review?.external?.required === true) frozen.push('review.external');
265
+ // B62a: a run started without a snapshot was isolated by export (or lock); turning worktree
266
+ // isolation on now is a change
267
+ if (config.proof?.isolation === 'worktree') frozen.push('proof.isolation');
268
+ // B63b: likewise, a run started without a snapshot never required the coder's tests
269
+ if (config.context?.require_coder_tests === true) frozen.push('context.require_coder_tests');
270
+ // B64b: and never had a block-end hook
271
+ if (config.hooks?.on_block_end !== undefined) frozen.push('hooks.on_block_end');
263
272
  }
264
273
  if (frozen.length > 0) {
265
274
  const why = frozen.map((key) => `${key} cannot change mid-run (${IMMUTABLE_KEYS[/** @type {keyof typeof IMMUTABLE_KEYS} */ (key)]})`);
@@ -27,6 +27,7 @@ import path from 'node:path';
27
27
  export const SUBCOMMANDS = Object.freeze({
28
28
  autopilot: ['start', 'status', 'stop'],
29
29
  block: ['open', 'attempt', 'rebase', 'claim', 'close', 'stop', 'waive'],
30
+ context: ['pack'],
30
31
  gates: ['detect', 'run', 'secret-scan', 'safe-edit', 'scope', 'acceptance', 'transcript-grep'],
31
32
  jev: ['ask'],
32
33
  keys: ['list', 'set', 'test', 'remove'],
@@ -277,7 +277,9 @@ export const FORBIDDEN = Object.freeze(
277
277
  * always appends them, so every list built for a coder carries them.
278
278
  * - `code-forge block waive` — waiving a review finding is human-only;
279
279
  * - `code-forge autopilot` (any subcommand, B45) — the owner's grant to a delegate;
280
- * - `--no-require-reviews` — the human's way out of `block close`'s per-file review check.
280
+ * - `--no-require-reviews` — the human's way out of `block close`'s per-file review check;
281
+ * - `--remove-worktree` (B62a) — `block close` removing the block's worktree, the orchestrator's
282
+ * alone. `block close` itself is not on this list, so the flag is listed on its own.
281
283
  * @type {ReadonlyArray<ForbiddenEntry>}
282
284
  */
283
285
  export const CODER_ONLY_FORBIDDEN = Object.freeze(
@@ -306,6 +308,12 @@ export const CODER_ONLY_FORBIDDEN = Object.freeze(
306
308
  tokens: ['--no-require-reviews'],
307
309
  description: 'Close a block without the per-file review check (--no-require-reviews) — human-only',
308
310
  },
311
+ {
312
+ id: 'code-forge-remove-worktree',
313
+ kind: 'contains',
314
+ tokens: ['--remove-worktree'],
315
+ description: "Remove a block's worktree at close (block close --remove-worktree, B62a) — the orchestrator only",
316
+ },
309
317
  ].map(freezeEntry),
310
318
  );
311
319
 
@@ -342,6 +350,33 @@ export function mergeForbidden(extraTokens = []) {
342
350
  return [...FORBIDDEN, ...CODER_ONLY_FORBIDDEN, ...extras];
343
351
  }
344
352
 
353
+ /** B64b: the id of the entry {@link hookForbidden} builds. */
354
+ export const HOOK_FORBIDDEN_ID = 'code-forge-on-block-end-hook';
355
+
356
+ /**
357
+ * B64b: the owner's `hooks.on_block_end` command as a coder-forbidden `prefix` entry — the hook
358
+ * runs only from the orchestrator's block-end verbs, never for a coder. The tokens are TRIMMED:
359
+ * the command name of `argv[0]` (its basename, as every reading normalizes it), plus `argv[1]`
360
+ * when there is one that is not blank (clause 3, "if any"). `argv[1]` stays ONE token even with
361
+ * inner whitespace — a literal match, as ruled. No entry (`[]`, so a list built with it is
362
+ * unchanged) when there is no hook (`argv` absent, empty, or not all non-empty strings), or when
363
+ * `argv[0]` is blank, names a directory (`/`, `dir/`), or is `.` or `..`. Appended by the caller
364
+ * after the static entries; it never replaces or weakens one.
365
+ * @param {unknown} argv
366
+ * @returns {ForbiddenEntry[]}
367
+ */
368
+ export function hookForbidden(argv) {
369
+ if (!Array.isArray(argv) || argv.length === 0 || !argv.every((a) => typeof a === 'string' && a.length > 0)) return [];
370
+ const first = argv[0].trim();
371
+ // a command name that is blank, a directory (`/`, `dir/`), `.` or `..` matches nothing a coder runs
372
+ if (first.length === 0 || /[/\\]$/.test(first)) return [];
373
+ const name = commandName(first).trim();
374
+ if (name.length === 0 || name === '.' || name === '..') return [];
375
+ const arg = argv.length > 1 ? argv[1].trim() : '';
376
+ const tokens = arg.length > 0 ? [name, arg] : [name];
377
+ return [freezeEntry({ id: HOOK_FORBIDDEN_ID, kind: 'prefix', tokens, description: 'Run the configured on_block_end hook (hooks.on_block_end) — it runs only at a block end, from the orchestrator\'s verbs' })];
378
+ }
379
+
345
380
  // ── Normalization ────────────────────────────────────────────────────────────
346
381
 
347
382
  /** git global options that take the NEXT argv token as their value (when not written `--opt=value`). */
@@ -0,0 +1,8 @@
1
+ /**
2
+ * The one argv item a gate uses for the block's related tests. It lives in `util` because two
3
+ * layers need the same value: `gates/expand.mjs` expands it, and `config/validate.mjs` refuses it as
4
+ * a gate's command (`config` may not import `gates`).
5
+ */
6
+
7
+ /** A gate argv item that is EXACTLY this string expands to the block's test files. */
8
+ export const TEST_FILES_TOKEN = '{test_files}';
@@ -40,29 +40,59 @@
40
40
  * The review NEVER approves an empty or failed session: that is `unavailable` (§4.2 stub guard).
41
41
  * A throw inside the round is `unavailable` too, with the state saved as `next: retry` (see
42
42
  * `fixLoopRound`): the next ticket re-runs that round, never a fresh round 1.
43
+ * B62b: for a block the run record gives a worktree (`proof.isolation: worktree`) every content
44
+ * read — the file, its diff against the block base, the peers' diffs, the fix hunks and the
45
+ * budget's file set — happens in that tree, against the tree's `base_sha` (`contentRootFor`; the
46
+ * tree comes from the run record, never from the coder-writable ticket), and the packet's facts
47
+ * slot names the tree and its branch. A file changed only in the main tree is never seen. The
48
+ * base, the tree and the facts come from ONE read of the run record. A ticket is `unavailable`
49
+ * with one of these reasons:
50
+ * - `worktree-missing`: the recorded tree is gone;
51
+ * - `worktree-not-dir`: its path is not a directory;
52
+ * - `worktree-malformed`: no absolute path, a branch that is empty or has control characters, or
53
+ * a base_sha that is not a full hex id;
54
+ * - `worktree-branch-mismatch`: the tree is not on its recorded branch;
55
+ * - `worktree-foreign`: the tree belongs to another repository;
56
+ * - `worktree-workspace-missing`: the workspace's subdirectory is not in the tree;
57
+ * - `worktree-mismatch`: the tree the worker hashed the ticket in (`ctx.contentRoot`, from the
58
+ * ticket's `tree` or its absence) is not the record's tree;
59
+ * - `git-failed`: git could not be run on the tree;
60
+ * - `run-record-unreadable`: the record cannot be parsed, or names no workspace.
61
+ * A block without a worktree, or one the run record does not know, reads the main tree as before.
62
+ * B63a: a ticket is also `unavailable` with `context-unreadable` when the config's `context` key
63
+ * is bad or one of its rules or lessons files is missing, outside the repository or unreadable at
64
+ * review time; no session runs and the packet dir is removed. The worker does not retry it (the
65
+ * ticket is done); `review-file` on the same content is a fresh attempt every time (`retry: true`).
66
+ * The tree is passed as `contentRoot`: `repoRoot` stays the main tree, and the fix-loop state
67
+ * stays under the run root.
43
68
  */
44
69
 
45
- import { rmSync } from 'node:fs';
70
+ import { realpathSync, rmSync } from 'node:fs';
46
71
  import path from 'node:path';
47
72
  import { blockKind } from '../decide/escalation.mjs';
48
73
  import { askJev } from '../decide/jev-client.mjs';
49
74
  import { planAndRecord, tierOf } from '../review/budget.mjs';
50
75
  import { budgetFor } from '../review/packet.mjs';
76
+ import { ContextError, reviewDigest } from '../context/pack.mjs';
51
77
  import { reviewFile, rulesRisk } from '../review/engine.mjs';
52
78
  import { peerDiffReader } from '../review/moved.mjs';
53
79
  import { computeFileSet, ownsFile } from '../gates/scope.mjs';
54
80
  import { extraRoundsFor, newFileState, reopenForExtraRound, runRound } from '../review/fixloop.mjs';
55
81
  import { readRun } from '../state/run.mjs';
82
+ import { StateError } from '../state/paths.mjs';
83
+ import { blockTree, worktreeRecord } from '../proof/worktree.mjs';
56
84
  import { ledgerSchemaHistory } from '../review/schema-fallback.mjs';
57
85
  import { verifyRow } from '../state/signer.mjs';
58
86
  import { redact } from '../util/redact.mjs';
59
87
  import { keyedLock } from '../util/locks.mjs';
60
88
  import { blockAnchors, blockRungUsed, loadState, saveState } from './review-state.mjs';
61
- import { assertTicketId } from './ticket.mjs';
89
+ import { assertTicketId, WorkerError } from './ticket.mjs';
62
90
 
63
91
  /**
64
92
  * @typedef {object} ReviewContext
65
- * @property {string} repoRoot
93
+ * @property {string} repoRoot - the main tree (the queue, the worker).
94
+ * @property {string} [contentRoot] - B62b: the tree the ticket's content is read in (the block's
95
+ * worktree); `repoRoot` when absent.
66
96
  * @property {string} runId
67
97
  * @property {string} runRootDir - the run's temp root (packets and session dirs live under it).
68
98
  * @property {Record<string, any>} cfg - the project config.
@@ -110,14 +140,17 @@ import { assertTicketId } from './ticket.mjs';
110
140
  * of its owned files. `ownedKinds` is the recorded `owned_kinds` (B57: which exact entries were
111
141
  * regular files at `block open`/`claim`), `{}` when there is none (every exact entry directory-like).
112
142
  * With no run record the kind is null: `reviewTicket` takes the ticket file's kind.
113
- * @returns {Promise<{base: string | null, level: string, owned: string[], ownedKinds: Record<string, string>, acceptance?: unknown, kind: string | null}>}
143
+ * `tree` (B62b) is the recorded `worktree` `{path, branch, base_sha}`, null when the block has
144
+ * none; `base` is then the tree's `base_sha`. A recorded worktree that `worktreeRecord` refuses
145
+ * (path, branch, hex base_sha) throws `worktree-malformed`.
146
+ * @returns {Promise<{base: string | null, level: string, owned: string[], ownedKinds: Record<string, string>, acceptance?: unknown, kind: string | null, tree: WorktreeEntry | null, workspace: string | null}>}
114
147
  */
115
148
  export async function blockEntryFor(runId, block) {
116
149
  let record;
117
150
  try {
118
151
  record = await readRun(runId);
119
152
  } catch (err) {
120
- if (/** @type {any} */ (err)?.code === 'no-run') return { base: null, level: 'L2', owned: [], ownedKinds: {}, acceptance: undefined, kind: null };
153
+ if (/** @type {any} */ (err)?.code === 'no-run') return { base: null, level: 'L2', owned: [], ownedKinds: {}, acceptance: undefined, kind: null, tree: null, workspace: null };
121
154
  throw new Error('run-record-unreadable');
122
155
  }
123
156
  if (!record || typeof record !== 'object' || !record.blocks || typeof record.blocks !== 'object') throw new Error('run-record-unreadable');
@@ -128,7 +161,85 @@ export async function blockEntryFor(runId, block) {
128
161
  const owned = Array.isArray(entry.owned_files) ? entry.owned_files.filter((f) => typeof f === 'string') : [];
129
162
  const kinds = entry.owned_kinds;
130
163
  const ownedKinds = kinds && typeof kinds === 'object' && !Array.isArray(kinds) ? kinds : {};
131
- return { base: sha, level: typeof entry.level === 'string' && /^L[0-3]$/.test(entry.level) ? entry.level : 'L2', owned, ownedKinds, acceptance: entry.acceptance, kind: blockKind({ owned, declared: entry.kind }) };
164
+ /** @type {WorktreeEntry | null} */
165
+ let tree = null;
166
+ if (entry.worktree !== undefined && entry.worktree !== null) {
167
+ try {
168
+ tree = worktreeRecord(entry.worktree);
169
+ } catch {
170
+ throw new Error('worktree-malformed');
171
+ }
172
+ }
173
+ return { base: tree ? tree.base_sha : sha, level: typeof entry.level === 'string' && /^L[0-3]$/.test(entry.level) ? entry.level : 'L2', owned, ownedKinds, acceptance: entry.acceptance, kind: blockKind({ owned, declared: entry.kind }), tree, workspace: typeof record.workspace === 'string' ? record.workspace : null };
174
+ }
175
+
176
+ /** @typedef {{path: string, branch: string, base_sha: string}} WorktreeEntry */
177
+
178
+ /** The refusals of {@link blockTree} a ticket answers with, as its `reason`. */
179
+ const TREE_CODES = new Set(['worktree-malformed', 'worktree-missing', 'worktree-not-dir', 'worktree-branch-mismatch', 'worktree-workspace-missing', 'worktree-foreign', 'git-failed']);
180
+
181
+ /**
182
+ * B62b: where the content of a ticket of `block` is read — the top of the block's worktree from
183
+ * the run record (`blockTree`: present, a directory, on its recorded branch, of this repository),
184
+ * else `repoRoot`. `repoRoot` exactly as before B62b when there is no run record, the block is not
185
+ * in it, or it has no `worktree`: only a block WITH a worktree record can be refused here.
186
+ * @param {string} runId @param {string} block @param {string} repoRoot
187
+ * @returns {Promise<string>}
188
+ * @throws {WorkerError} a code of {@link TREE_CODES}, or `run-record-unreadable` for a record that
189
+ * cannot be parsed or is not a record; any other failure (an I/O error) is rethrown.
190
+ */
191
+ export async function contentRootFor(runId, block, repoRoot) {
192
+ let record;
193
+ try {
194
+ record = await readRun(runId);
195
+ } catch (err) {
196
+ if (/** @type {any} */ (err)?.code === 'no-run') return repoRoot;
197
+ if (err instanceof SyntaxError) throw new WorkerError('run-record-unreadable', `block ${block}: the run record cannot be read`);
198
+ throw err;
199
+ }
200
+ if (record === null || typeof record !== 'object') throw new WorkerError('run-record-unreadable', `block ${block}: the run record cannot be read`);
201
+ const blocks = record.blocks;
202
+ const entry = blocks && typeof blocks === 'object' && typeof block === 'string' && Object.hasOwn(blocks, block) ? blocks[block] : null;
203
+ if (entry?.worktree === undefined || entry.worktree === null) return repoRoot;
204
+ return treeTopOf(record.workspace, block, entry.worktree);
205
+ }
206
+
207
+ /**
208
+ * The realpath'd top of the recorded worktree `worktree` of `block` (`blockTree`).
209
+ * @param {string} workspace @param {string} block @param {unknown} worktree
210
+ * @returns {Promise<string>}
211
+ * @throws {WorkerError} a code of {@link TREE_CODES}; any other failure is rethrown.
212
+ */
213
+ async function treeTopOf(workspace, block, worktree) {
214
+ if (typeof workspace !== 'string' || !path.isAbsolute(workspace)) throw new WorkerError('run-record-unreadable', `block ${block}: the run record names no workspace`);
215
+ try {
216
+ return /** @type {import('../proof/worktree.mjs').BlockTree} */ (await blockTree(workspace, { worktree })).top;
217
+ } catch (err) {
218
+ const code = /** @type {any} */ (err)?.code;
219
+ if (err instanceof StateError && TREE_CODES.has(code)) throw new WorkerError(code, `block ${block}: ${err.message}`);
220
+ throw err;
221
+ }
222
+ }
223
+
224
+ /** @param {string} p @returns {string} `p` realpath'd, or `p` itself when it cannot be resolved. */
225
+ export function canonicalPath(p) {
226
+ try {
227
+ return realpathSync(p);
228
+ } catch {
229
+ return p;
230
+ }
231
+ }
232
+
233
+ /**
234
+ * The packet's facts slot: the acceptance clauses, and for a block with a worktree (B62b) a line
235
+ * naming the tree and its branch the file is reviewed in.
236
+ * @param {{acceptance?: unknown, tree: WorktreeEntry | null}} entry @param {Record<string, any> | undefined} cfg
237
+ * @returns {string}
238
+ */
239
+ function factsFor(entry, cfg) {
240
+ const excerpt = acceptanceExcerpt(entry.acceptance, cfg);
241
+ if (!entry.tree) return excerpt;
242
+ return `${excerpt}\nWorktree: the file is reviewed as it is in the block's worktree ${redact(entry.tree.path)} (branch ${redact(entry.tree.branch)}), against the block base.`;
132
243
  }
133
244
 
134
245
  /** The acceptance line written when the block's clauses cannot be read. */
@@ -213,15 +324,43 @@ export async function reviewTicket(ticket, ctx) {
213
324
  return { status: 'unavailable', reason: err instanceof Error ? err.message : 'run-record-unreadable', approved: false, engine: 'adaptive', sessions: [] };
214
325
  }
215
326
  if (entry.kind === null) entry = { ...entry, kind: blockKind({ owned: [ticket.file] }) };
216
- const peerDiffs = blockPeerDiffs(ctx.repoRoot, { base: entry.base, owned: entry.owned, ownedKinds: entry.ownedKinds, file: ticket.file });
327
+ // B62b: every content read of this ticket happens in the block's tree (the main tree without
328
+ // one), taken from the SAME entry as the base and the facts. The worker passes the tree it
329
+ // hashed the ticket in as `ctx.contentRoot`; a record that changed since names another tree.
330
+ let contentRoot;
217
331
  try {
332
+ contentRoot = entry.tree ? await treeTopOf(entry.workspace, ticket.block, entry.tree) : ctx.repoRoot;
333
+ } catch (err) {
334
+ return { status: 'unavailable', reason: err instanceof WorkerError ? err.code : 'run-record-unreadable', approved: false, engine: 'adaptive', sessions: [] };
335
+ }
336
+ if (ctx.contentRoot !== undefined && canonicalPath(ctx.contentRoot) !== canonicalPath(contentRoot)) {
337
+ return { status: 'unavailable', reason: 'worktree-mismatch', approved: false, engine: 'adaptive', sessions: [] };
338
+ }
339
+ /** @type {ReviewContext} */
340
+ const tctx = { ...ctx, contentRoot };
341
+ const factsExcerpt = factsFor(entry, ctx.cfg);
342
+ const peerDiffs = blockPeerDiffs(contentRoot, { base: entry.base, owned: entry.owned, ownedKinds: entry.ownedKinds, file: ticket.file });
343
+ try {
344
+ // B63a: the `context` rules and lessons, read from the MAIN tree (`ctx.repoRoot`, never the
345
+ // block's worktree) with the config in force for this ticket; '' without a `context` key, so
346
+ // the packet stays as it was. A bad `context` value or a rules/lessons file that is missing,
347
+ // outside the repository or unreadable makes the ticket `unavailable` (`context-unreadable`,
348
+ // re-queued by `review-file` on the same content, as often as asked): no review runs without the rules it was promised.
349
+ let rulesDigest;
350
+ try {
351
+ rulesDigest = reviewDigest({ repoRoot: ctx.repoRoot, cfg: ctx.cfg });
352
+ } catch (err) {
353
+ if (!(err instanceof ContextError)) throw err;
354
+ return { status: 'unavailable', reason: 'context-unreadable', approved: false, engine: 'adaptive', sessions: [] };
355
+ }
356
+ const digest = rulesDigest === '' ? {} : { rulesDigest };
218
357
  if (!ctx.key) {
219
358
  return await reviewFile(
220
- { repoRoot: ctx.repoRoot, file: ticket.file, base: entry.base, cfg: ctx.cfg, kind: entry.kind, workDir, factsExcerpt: acceptanceExcerpt(entry.acceptance, ctx.cfg), peerDiffs },
359
+ { repoRoot: contentRoot, file: ticket.file, base: entry.base, cfg: ctx.cfg, kind: entry.kind, workDir, factsExcerpt, ...digest, peerDiffs },
221
360
  { spawn: ctx.spawn, ...(ctx.writeRow ? { writeRow: ctx.writeRow } : {}) },
222
361
  );
223
362
  }
224
- return await fixLoopRound(ticket, ctx, { base: entry.base, level: entry.level, owned: entry.owned, ownedKinds: entry.ownedKinds, blockKindOf: entry.kind, key: ctx.key, workDir, factsExcerpt: acceptanceExcerpt(entry.acceptance, ctx.cfg), peerDiffs });
363
+ return await fixLoopRound(ticket, tctx, { base: entry.base, level: entry.level, owned: entry.owned, ownedKinds: entry.ownedKinds, blockKindOf: entry.kind, key: ctx.key, workDir, factsExcerpt, rulesDigest, peerDiffs });
225
364
  } finally {
226
365
  rmSync(workDir, { recursive: true, force: true });
227
366
  }
@@ -329,10 +468,10 @@ export function mayTakeRung(state, kind, cfg, rungUsed) {
329
468
  * first full round, or any round once the rung is gone) runs outside the block lock, in parallel
330
469
  * with the block's other files.
331
470
  * @param {import('./queue.mjs').Ticket} ticket @param {ReviewContext} ctx
332
- * @param {{base: string | null, level: string, owned: string[], ownedKinds?: Record<string, string>, blockKindOf: string, key: Buffer, workDir: string, factsExcerpt: string, peerDiffs?: () => Promise<import('../review/moved.mjs').PeerDiff[]>}} opts
471
+ * @param {{base: string | null, level: string, owned: string[], ownedKinds?: Record<string, string>, blockKindOf: string, key: Buffer, workDir: string, factsExcerpt: string, rulesDigest?: string, peerDiffs?: () => Promise<import('../review/moved.mjs').PeerDiff[]>}} opts
333
472
  * @returns {Promise<ReviewOutcome>}
334
473
  */
335
- async function fixLoopRound(ticket, ctx, { base, level, owned, ownedKinds, blockKindOf, key, workDir, factsExcerpt, peerDiffs }) {
474
+ async function fixLoopRound(ticket, ctx, { base, level, owned, ownedKinds, blockKindOf, key, workDir, factsExcerpt, rulesDigest = '', peerDiffs }) {
336
475
  if (!ctx.readRows || !ctx.writeRow) return { status: 'unavailable', reason: 'no-ledger', approved: false, engine: 'adaptive', sessions: [] };
337
476
  const readRows = ctx.readRows;
338
477
  /** @type {{outcome: ReviewOutcome} | {round: () => Promise<ReviewOutcome>}} */
@@ -372,7 +511,7 @@ async function fixLoopRound(ticket, ctx, { base, level, owned, ownedKinds, block
372
511
  state.l3_rung_used = true; // the orchestrator ran the block's one L3 patch before this ticket
373
512
  }
374
513
  const fixed = state;
375
- const round = () => playRound(ticket, ctx, { state: fixed, kind, where, seq: loaded.seq, base, blockKindOf, workDir, factsExcerpt, extraRounds, peerDiffs });
514
+ const round = () => playRound(ticket, ctx, { state: fixed, kind, where, seq: loaded.seq, base, blockKindOf, workDir, factsExcerpt, rulesDigest, extraRounds, peerDiffs });
376
515
  // a round that could take the rung keeps the block lock until its state is saved
377
516
  if (mayTakeRung(fixed, kind, ctx.cfg, rungUsed)) return { outcome: await round() };
378
517
  return { round };
@@ -383,10 +522,10 @@ async function fixLoopRound(ticket, ctx, { base, level, owned, ownedKinds, block
383
522
  /**
384
523
  * Run the prepared round and save the file's state: the ticket's outcome.
385
524
  * @param {import('./queue.mjs').Ticket} ticket @param {ReviewContext} ctx
386
- * @param {{state: import('../review/fixloop.mjs').FileState, kind: 'full' | 'recheck' | 'patch_check', where: import('./review-state.mjs').Where, seq: number, base: string | null, blockKindOf: string, workDir: string, factsExcerpt: string, extraRounds?: number, peerDiffs?: () => Promise<import('../review/moved.mjs').PeerDiff[]>}} opts
525
+ * @param {{state: import('../review/fixloop.mjs').FileState, kind: 'full' | 'recheck' | 'patch_check', where: import('./review-state.mjs').Where, seq: number, base: string | null, blockKindOf: string, workDir: string, factsExcerpt: string, rulesDigest?: string, extraRounds?: number, peerDiffs?: () => Promise<import('../review/moved.mjs').PeerDiff[]>}} opts
387
526
  * @returns {Promise<ReviewOutcome>}
388
527
  */
389
- async function playRound(ticket, ctx, { state, kind, where, seq, base, blockKindOf, workDir, factsExcerpt, extraRounds = 0, peerDiffs }) {
528
+ async function playRound(ticket, ctx, { state, kind, where, seq, base, blockKindOf, workDir, factsExcerpt, rulesDigest = '', extraRounds = 0, peerDiffs }) {
390
529
  /** @type {Record<string, any> | null} */
391
530
  let engineOutcome = null;
392
531
  let approvalWritten = false;
@@ -429,18 +568,19 @@ async function playRound(ticket, ctx, { state, kind, where, seq, base, blockKind
429
568
  await runRound(
430
569
  state,
431
570
  {
432
- repoRoot: ctx.repoRoot,
571
+ repoRoot: ctx.contentRoot ?? ctx.repoRoot,
433
572
  cfg: ctx.cfg,
434
573
  workDir,
435
574
  base,
436
575
  writeRow,
437
576
  factsExcerpt,
577
+ ...(rulesDigest !== '' ? { rulesDigest } : {}),
438
578
  extraRounds,
439
579
  ...(peerDiffs ? { peerDiffs } : {}),
440
580
  ...(jev ? { jev } : {}),
441
581
  ...(schemaHistory ? { schemaHistory } : {}),
442
582
  review: async () => {
443
- engineOutcome = await reviewFile({ repoRoot: ctx.repoRoot, file: ticket.file, base, cfg: ctx.cfg, kind: blockKindOf, workDir, factsExcerpt, ...(peerDiffs ? { peerDiffs } : {}) }, { spawn: ctx.spawn, writeRow, ...(schemaHistory ? { schemaHistory } : {}) });
583
+ engineOutcome = await reviewFile({ repoRoot: ctx.contentRoot ?? ctx.repoRoot, file: ticket.file, base, cfg: ctx.cfg, kind: blockKindOf, workDir, factsExcerpt, ...(rulesDigest !== '' ? { rulesDigest } : {}), ...(peerDiffs ? { peerDiffs } : {}) }, { spawn: ctx.spawn, writeRow, ...(schemaHistory ? { schemaHistory } : {}) });
444
584
  return engineOutcome;
445
585
  },
446
586
  spawn: async (opts) => {
@@ -500,7 +640,7 @@ async function recordBlockBudget(ticket, ctx, { base, owned, ownedKinds }) {
500
640
  let changed = [];
501
641
  if (base !== null && owned.length > 0) {
502
642
  try {
503
- changed = (await computeFileSet({ cwd: ctx.repoRoot, base })).all.filter((f) => ownsFile(owned, f, ownedKinds));
643
+ changed = (await computeFileSet({ cwd: ctx.contentRoot ?? ctx.repoRoot, base })).all.filter((f) => ownsFile(owned, f, ownedKinds));
504
644
  } catch {
505
645
  // no row on a git failure: a ticket-only forecast would be permanent (the row is written
506
646
  // once per block); the block's next keyed ticket retries
@@ -50,7 +50,7 @@ import { keyedLock } from '../util/locks.mjs';
50
50
  import { writeSafe } from '../util/redact.mjs';
51
51
  import { runRoot, setRunRoot } from '../util/tmp.mjs';
52
52
  import { openFindings } from '../review/gate-check.mjs';
53
- import { reviewTicket } from './engine.mjs';
53
+ import { canonicalPath, contentRootFor, reviewTicket } from './engine.mjs';
54
54
  import { announceWorker, beatWorker, HEARTBEAT_MS, liveWorker, pendingTickets, readTicket, retractWorker, writeResult } from './queue.mjs';
55
55
  import { contentHash, DELETED_HASH, repoRootOf, WorkerError } from './ticket.mjs';
56
56
 
@@ -285,13 +285,15 @@ export async function createWorker(opts, deps = {}) {
285
285
  * One ticket through the engine hook with `tcfg` (the ticket's config snapshot): the unsigned result.
286
286
  * @param {import('./queue.mjs').Ticket} ticket @param {Record<string, any>} base
287
287
  * @param {Record<string, any>} tcfg
288
+ * @param {string} contentRoot - B62b: the tree the content was hashed in, and is reviewed in.
288
289
  * @returns {Promise<Record<string, any>>}
289
290
  */
290
- async function reviewWith(ticket, base, tcfg) {
291
+ async function reviewWith(ticket, base, tcfg, contentRoot) {
291
292
  const timeoutS = Number.isInteger(tcfg?.review?.session_timeout_s) && tcfg.review.session_timeout_s > 0 ? tcfg.review.session_timeout_s : DEFAULT_SESSION_TIMEOUT_S;
292
293
  /** @type {import('./engine.mjs').ReviewContext} */
293
294
  const ctx = {
294
295
  repoRoot,
296
+ contentRoot,
295
297
  runId,
296
298
  runRootDir,
297
299
  cfg: tcfg,
@@ -360,15 +362,32 @@ export async function createWorker(opts, deps = {}) {
360
362
  */
361
363
  async function reviewOne(id, ticket) {
362
364
  const base = { event: 'review.result', block: ticket.block, file: ticket.file, content_hash: ticket.content_hash };
363
- /** @type {string | null} */
365
+ if (ticket.run !== runId) return { ...base, status: 'refused', reason: 'other-run', approved: false };
366
+ // B62b: the tree the content is hashed AND reviewed in — a block's worktree (from the run
367
+ // record, as review-file did), else the main tree. A tree that cannot be used (gone, malformed)
368
+ // or a block entry that cannot be read is `unavailable` with its code: never approval, and a
369
+ // later ticket for the same content runs again once it is fixed.
370
+ let contentRoot;
371
+ try {
372
+ contentRoot = await contentRootFor(runId, ticket.block, repoRoot);
373
+ } catch (err) {
374
+ // only the codes contentRootFor answers with; an I/O failure goes to runOne (`worker-error`)
375
+ if (!(err instanceof WorkerError)) throw err;
376
+ return { ...base, status: 'unavailable', reason: err.code, approved: false };
377
+ }
378
+ // a ticket was hashed in the tree it names (the main tree when it names none): never review it
379
+ // in another one — also a ticket with no tree for a block the record gives a worktree
380
+ const hashedIn = typeof ticket.tree === 'string' ? ticket.tree : repoRoot;
381
+ if (canonicalPath(hashedIn) !== canonicalPath(contentRoot)) return { ...base, status: 'unavailable', reason: 'worktree-mismatch', approved: false };
382
+ /** @type {string} */
364
383
  let current;
365
384
  try {
366
- current = ticket.run === runId ? contentHash(repoRoot, ticket.file) : null;
385
+ current = contentHash(contentRoot, ticket.file);
367
386
  } catch (err) {
368
387
  // the path no longer passes the on-disk check, or the file cannot be read
369
- return { ...base, status: 'refused', reason: err instanceof WorkerError ? err.code : 'unreadable', approved: false };
388
+ const reason = err instanceof WorkerError ? err.code : 'unreadable';
389
+ return { ...base, status: 'refused', reason, approved: false };
370
390
  }
371
- if (ticket.run !== runId) return { ...base, status: 'refused', reason: 'other-run', approved: false };
372
391
  if (current === DELETED_HASH && ticket.content_hash !== DELETED_HASH) return { ...base, status: 'stale', reason: 'missing-file', approved: false };
373
392
  if (current !== ticket.content_hash) return { ...base, status: 'stale', approved: false };
374
393
  let ticketCfg;
@@ -377,7 +396,7 @@ export async function createWorker(opts, deps = {}) {
377
396
  } catch {
378
397
  return { ...base, status: 'unavailable', reason: 'config-snapshot', approved: false };
379
398
  }
380
- return reviewWith(ticket, base, ticketCfg);
399
+ return reviewWith(ticket, base, ticketCfg, contentRoot);
381
400
  }
382
401
 
383
402
  /**