@codedology/code-forge 0.7.6 → 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 (63) hide show
  1. package/CHANGELOG.md +51 -0
  2. package/README.md +57 -2
  3. package/docs/README.md +2 -1
  4. package/docs/reference/blocks.json +171 -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/plan.md +12 -0
  12. package/skill/references/proof.md +44 -3
  13. package/skill/references/review.md +4 -2
  14. package/skill/references/security.md +1 -1
  15. package/skill/scripts/forge +1 -1
  16. package/skill/templates/coder-brief.md +18 -1
  17. package/src/autopilot/binnacle.mjs +21 -3
  18. package/src/cli/block.mjs +337 -37
  19. package/src/cli/context.mjs +175 -0
  20. package/src/cli/gates.mjs +125 -15
  21. package/src/cli/ledger.mjs +46 -2
  22. package/src/cli/proof.mjs +62 -16
  23. package/src/cli/report.mjs +21 -4
  24. package/src/cli/review-file.mjs +61 -6
  25. package/src/cli/run.mjs +16 -6
  26. package/src/cli/status.mjs +212 -0
  27. package/src/config/validate.mjs +45 -11
  28. package/src/context/pack.mjs +319 -0
  29. package/src/decide/lane-facts.mjs +41 -0
  30. package/src/doctor/local.mjs +2 -1
  31. package/src/engines/sentinel.mjs +27 -6
  32. package/src/gates/detect.mjs +21 -8
  33. package/src/gates/expand.mjs +202 -0
  34. package/src/gates/owned-count.mjs +66 -0
  35. package/src/gates/run.mjs +49 -12
  36. package/src/install/wizard/answers.mjs +34 -5
  37. package/src/install/wizard/flags.mjs +8 -1
  38. package/src/install/wizard/project-file.mjs +2 -0
  39. package/src/install/wizard/run.mjs +6 -0
  40. package/src/install/wizard/steps.mjs +24 -4
  41. package/src/install/wizard/summary.mjs +13 -5
  42. package/src/ledger/export.mjs +1 -1
  43. package/src/ledger/outside-review.mjs +164 -0
  44. package/src/ledger/report.mjs +32 -6
  45. package/src/proof/export.mjs +93 -10
  46. package/src/proof/lock.mjs +28 -5
  47. package/src/proof/worktree.mjs +482 -0
  48. package/src/review/engine.mjs +3 -2
  49. package/src/review/gate-check.mjs +9 -5
  50. package/src/review/open-findings.mjs +59 -0
  51. package/src/state/block-end.mjs +290 -0
  52. package/src/state/block.mjs +111 -37
  53. package/src/state/brief-status.mjs +131 -0
  54. package/src/state/config-snapshot.mjs +68 -8
  55. package/src/state/registry.mjs +35 -4
  56. package/src/state/run.mjs +21 -2
  57. package/src/util/breadcrumbs.mjs +1 -0
  58. package/src/util/forbidden.mjs +36 -1
  59. package/src/util/gate-token.mjs +8 -0
  60. package/src/worker/engine.mjs +157 -17
  61. package/src/worker/loop.mjs +26 -7
  62. package/src/worker/queue.mjs +36 -10
  63. package/src/worker/ticket.mjs +46 -6
@@ -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
  /**
@@ -9,10 +9,13 @@
9
9
  *
10
10
  * The forbidden list (§8.4 ★) denies a coder any write under `reviews/` and to `queue/*.done`;
11
11
  * the MAC makes an edit visible anyway (T1/T2). Writes are atomic (temp file + rename).
12
+ *
13
+ * B62b: `<repo>` is always the MAIN tree's top level, where the worker runs — a block with its own
14
+ * worktree still queues there; only the reviewed content is read in the block's tree.
12
15
  */
13
16
 
14
17
  import { randomBytes } from 'node:crypto';
15
- import { existsSync, mkdirSync, readdirSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs';
18
+ import { existsSync, mkdirSync, readdirSync, readFileSync, realpathSync, renameSync, rmSync, writeFileSync } from 'node:fs';
16
19
  import path from 'node:path';
17
20
  import { loadKey, signRow, verifyRow } from '../state/signer.mjs';
18
21
  import { isAlive, readStartTime, UNKNOWN_START_TIME } from '../util/reaper.mjs';
@@ -54,26 +57,48 @@ function readJSON(file) {
54
57
  }
55
58
 
56
59
  /**
57
- * @typedef {{ticket: string, run: string, block: string, file: string, content_hash: string, enqueued_at: string}} Ticket
60
+ * @typedef {{ticket: string, run: string, block: string, file: string, content_hash: string, enqueued_at: string, tree?: string}} Ticket
61
+ * `tree` (B62b): the block's worktree the content was hashed in. It is part of the ticket id,
62
+ * and the worker only checks it against the tree it takes from the run record (a ticket whose
63
+ * tree is not that one is `unavailable`, `worktree-mismatch`); it never reads content there.
58
64
  */
59
65
 
60
66
  /**
61
67
  * Enqueue `file` (already repo-root-relative) for review. Idempotent by content hash: the same
62
- * content returns the same ticket and never rewrites it.
63
- * @param {{repoRoot: string, run: string, block: string, file: string, now?: Date}} opts
64
- * @returns {{ticket: string, status: 'queued' | 'done', file: string, content_hash: string}}
68
+ * content returns the same ticket and never rewrites it. The queue is under `repoRoot`; the
69
+ * content is hashed under `tree` when given (B62b: the block's worktree, realpath'd here), else
70
+ * under `repoRoot`. The tree is part of the ticket id, so a main-tree ticket and a tree ticket for
71
+ * the same bytes differ, and the answer names the tree given here, never a stored value.
72
+ * @param {{repoRoot: string, run: string, block: string, file: string, tree?: string | null, now?: Date}} opts
73
+ * @returns {{ticket: string, status: 'queued' | 'done', file: string, content_hash: string, tree?: string}}
74
+ * @throws {WorkerError} `bad-path` for a relative `tree` or one that cannot be resolved.
65
75
  */
66
- export function enqueue({ repoRoot, run, block, file, now = new Date() }) {
76
+ export function enqueue({ repoRoot, run, block, file, tree = null, now = new Date() }) {
67
77
  const rel = assertRowPath(file);
68
- const content_hash = contentHash(repoRoot, rel);
69
- const ticket = ticketId({ run, block, file: rel, content_hash });
78
+ const root = tree === null ? null : realTree(tree);
79
+ const content_hash = contentHash(root ?? repoRoot, rel);
80
+ const ticket = ticketId({ run, block, file: rel, content_hash, tree: root });
70
81
  const target = path.join(queueDir(repoRoot), `${ticket}.json`);
82
+ const named = root === null ? {} : { tree: root };
71
83
  if (!existsSync(target)) {
72
84
  /** @type {Ticket} */
73
- const body = { ticket, run, block, file: rel, content_hash, enqueued_at: now.toISOString() };
85
+ const body = { ticket, run, block, file: rel, content_hash, enqueued_at: now.toISOString(), ...named };
74
86
  writeAtomic(target, `${JSON.stringify(body)}\n`);
75
87
  }
76
- return { ticket, status: isDone(repoRoot, ticket) ? 'done' : 'queued', file: rel, content_hash };
88
+ return { ticket, status: isDone(repoRoot, ticket) ? 'done' : 'queued', file: rel, content_hash, ...named };
89
+ }
90
+
91
+ /**
92
+ * @param {string} tree @returns {string} `tree` realpath'd.
93
+ * @throws {WorkerError} `bad-path` when it is relative or cannot be resolved.
94
+ */
95
+ function realTree(tree) {
96
+ if (!path.isAbsolute(tree)) throw new WorkerError('bad-path', `the content tree ${tree} is not an absolute path`);
97
+ try {
98
+ return realpathSync(tree);
99
+ } catch {
100
+ throw new WorkerError('bad-path', `the content tree ${tree} cannot be resolved`);
101
+ }
77
102
  }
78
103
 
79
104
  /** @param {string} repoRoot @param {string} ticket */
@@ -113,6 +138,7 @@ export function readTicket(repoRoot, id) {
113
138
  const t = readJSON(path.join(queueDir(repoRoot), `${assertTicketId(id)}.json`));
114
139
  if (!t || typeof t !== 'object') throw new WorkerError('bad-ticket', `ticket ${id} is not readable JSON`);
115
140
  assertRowPath(t.file);
141
+ if (t.tree !== undefined && (typeof t.tree !== 'string' || !path.isAbsolute(t.tree))) throw new WorkerError('bad-ticket', `ticket ${id} names a tree that is not an absolute path`);
116
142
  if (typeof t.run !== 'string' || typeof t.block !== 'string' || typeof t.content_hash !== 'string' || ticketId(t) !== id) {
117
143
  throw new WorkerError('bad-ticket', `ticket ${id} does not match its id`);
118
144
  }
@@ -1,6 +1,7 @@
1
1
  /**
2
2
  * Review tickets (plan §4.8, V4; block B11): repo-root-relative paths, content hashes and ticket
3
- * ids. Pure functions plus the one `git rev-parse --show-toplevel` call.
3
+ * ids. Pure functions plus the `git rev-parse` calls that find the repository root (and, B62b,
4
+ * the main working tree of a linked worktree: `mainRootOf`).
4
5
  *
5
6
  * Path rule (V4): a ticket's `file` — and every `review.*` row the worker writes — is relative to
6
7
  * the REPOSITORY ROOT (realpath'd `git rev-parse --show-toplevel`), whatever directory the coder
@@ -8,8 +9,9 @@
8
9
  * `bad-path` before anything is written; so is a result outside the root or under `.git/` or
9
10
  * `.code-forge/`. A stored value is re-checked by `assertRowPath` (tickets are coder-writable).
10
11
  *
11
- * Ticket id = the first 24 hex chars of `sha256(run \0 block \0 file \0 content_hash)`: the same
12
- * content enqueued twice is the same ticket (idempotent), an edited file is a new one.
12
+ * Ticket id = the first 24 hex chars of `sha256(run \0 block \0 file \0 content_hash)`, plus
13
+ * `\0 tree` for content hashed in a block's worktree (B62b): the same content enqueued twice is
14
+ * the same ticket (idempotent), an edited file is a new one.
13
15
  */
14
16
 
15
17
  import { createHash } from 'node:crypto';
@@ -73,6 +75,39 @@ export async function repoRootOf(cwd) {
73
75
  return realpathSync(top);
74
76
  }
75
77
 
78
+ /**
79
+ * B62b: the top level of the MAIN working tree of the repository containing `cwd`, realpath'd —
80
+ * `cwd`'s own top level ({@link repoRootOf}) unless `cwd` is inside a linked worktree (`git
81
+ * worktree add`), whose main tree is the first record `git worktree list` prints. The review queue
82
+ * and the worker live there, whichever tree the coder works in. A repository whose main entry is
83
+ * bare has no main working tree to hold them, and is refused.
84
+ * @param {string} cwd
85
+ * @returns {Promise<string>}
86
+ * @throws {WorkerError} `no-repo` (also for git older than 2.36), `bare-main`, `no-main-tree`
87
+ */
88
+ export async function mainRootOf(cwd) {
89
+ const top = await repoRootOf(cwd);
90
+ const opts = { cwd, env: gitChildEnv(), timeoutMs: 10000 };
91
+ const dirs = await exec(['git', 'rev-parse', '--path-format=absolute', '--git-dir', '--git-common-dir'], opts);
92
+ if (dirs.result !== 'ok') throw new WorkerError('no-repo', `not inside a git repository, or git is older than 2.36 (code-forge needs git >= 2.36): ${dirs.stderr.trim()}`);
93
+ const [gitDir, commonDir] = dirs.stdout.split('\n');
94
+ if (!gitDir || !commonDir) throw new WorkerError('no-repo', 'git rev-parse printed no git directory');
95
+ if (realOrSelf(gitDir) === realOrSelf(commonDir)) return top;
96
+ const list = await exec(['git', 'worktree', 'list', '--porcelain', '-z'], opts);
97
+ if (list.result !== 'ok') throw new WorkerError('no-repo', `the main working tree of the repository cannot be listed (code-forge needs git >= 2.36): ${list.stderr.trim()}`);
98
+ // records end with an empty field; the first record is the main tree's
99
+ const first = list.stdout.split('\0\0')[0].split('\0');
100
+ const where = first.find((line) => line.startsWith('worktree '));
101
+ if (!where) throw new WorkerError('no-repo', 'git worktree list printed no main working tree');
102
+ if (first.includes('bare')) throw new WorkerError('bare-main', `the repository's main entry ${where.slice('worktree '.length)} is bare: it has no main working tree for the review queue`);
103
+ const main = where.slice('worktree '.length);
104
+ try {
105
+ return realpathSync(main);
106
+ } catch {
107
+ throw new WorkerError('no-main-tree', `the repository's main working tree ${main} does not exist (moved or removed): the review queue lives there`);
108
+ }
109
+ }
110
+
76
111
  /**
77
112
  * Refuse a stored row path that is not a plain repo-root-relative POSIX path (V4).
78
113
  * @param {unknown} value
@@ -187,9 +222,14 @@ export function readRegularFileNoFollow(repoRoot, file) {
187
222
  }
188
223
 
189
224
  /**
190
- * @param {{run: string, block: string, file: string, content_hash: string}} t
225
+ * B62b: a ticket that names the tree its content was hashed in (`tree`, a block's worktree) has
226
+ * the tree in its identity too — `sha256(run \0 block \0 file \0 content_hash \0 tree)` — so a
227
+ * main-tree ticket and a tree ticket for the same bytes are two tickets. Without a tree the id is
228
+ * the one from before B62b.
229
+ * @param {{run: string, block: string, file: string, content_hash: string, tree?: string | null}} t
191
230
  * @returns {string} the ticket id.
192
231
  */
193
- export function ticketId({ run, block, file, content_hash }) {
194
- return createHash('sha256').update([run, block, file, content_hash].join('\0')).digest('hex').slice(0, 24);
232
+ export function ticketId({ run, block, file, content_hash, tree = null }) {
233
+ const parts = tree === null ? [run, block, file, content_hash] : [run, block, file, content_hash, tree];
234
+ return createHash('sha256').update(parts.join('\0')).digest('hex').slice(0, 24);
195
235
  }