@codedology/code-forge 0.7.7 → 0.9.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 (69) hide show
  1. package/CHANGELOG.md +97 -0
  2. package/README.md +68 -2
  3. package/docs/README.md +2 -1
  4. package/docs/reference/blocks.json +390 -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 +45 -7
  10. package/skill/references/ledger.md +8 -4
  11. package/skill/references/proof.md +44 -3
  12. package/skill/references/review.md +25 -5
  13. package/skill/references/security.md +1 -1
  14. package/skill/scripts/forge +1 -1
  15. package/skill/templates/coder-brief.md +19 -2
  16. package/src/autopilot/binnacle.mjs +21 -3
  17. package/src/cli/block.mjs +427 -41
  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 +324 -18
  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/index.mjs +5 -4
  28. package/src/doctor/local.mjs +91 -3
  29. package/src/engines/sentinel.mjs +27 -6
  30. package/src/gates/detect.mjs +21 -8
  31. package/src/gates/expand.mjs +202 -0
  32. package/src/gates/owned-count.mjs +66 -0
  33. package/src/gates/run.mjs +49 -12
  34. package/src/install/wizard/answers.mjs +34 -5
  35. package/src/install/wizard/flags.mjs +8 -1
  36. package/src/install/wizard/project-file.mjs +2 -0
  37. package/src/install/wizard/run.mjs +6 -0
  38. package/src/install/wizard/steps.mjs +24 -4
  39. package/src/install/wizard/summary.mjs +13 -5
  40. package/src/ledger/export.mjs +1 -1
  41. package/src/ledger/outside-review.mjs +164 -0
  42. package/src/ledger/report.mjs +17 -5
  43. package/src/ledger/write.mjs +7 -1
  44. package/src/proof/export.mjs +93 -10
  45. package/src/proof/lock.mjs +28 -5
  46. package/src/proof/worktree.mjs +482 -0
  47. package/src/review/answers.mjs +445 -0
  48. package/src/review/deletion.mjs +106 -0
  49. package/src/review/engine.mjs +36 -2
  50. package/src/review/fixloop.mjs +25 -15
  51. package/src/review/gate-check.mjs +384 -83
  52. package/src/review/open-findings.mjs +59 -0
  53. package/src/review/reopen.mjs +258 -0
  54. package/src/review/stop-exits.mjs +15 -0
  55. package/src/state/block-end.mjs +290 -0
  56. package/src/state/block.mjs +103 -39
  57. package/src/state/brief-status.mjs +131 -0
  58. package/src/state/config-snapshot.mjs +51 -0
  59. package/src/state/registry.mjs +35 -4
  60. package/src/state/run.mjs +10 -1
  61. package/src/util/breadcrumbs.mjs +1 -0
  62. package/src/util/forbidden.mjs +46 -1
  63. package/src/util/gate-token.mjs +8 -0
  64. package/src/util/tmp.mjs +16 -4
  65. package/src/worker/engine.mjs +230 -24
  66. package/src/worker/loop.mjs +29 -8
  67. package/src/worker/queue.mjs +296 -15
  68. package/src/worker/review-state.mjs +346 -51
  69. package/src/worker/ticket.mjs +46 -6
package/src/util/tmp.mjs CHANGED
@@ -130,18 +130,30 @@ export function tmpBase(root) {
130
130
  }
131
131
 
132
132
  /**
133
- * Create (if needed) the run root `<base>/<runId>/`, record this process as its owner, and
134
- * return its real absolute path.
133
+ * The run root's path `<base>/<runId>`, without creating anything (B69: doctor looks for old
134
+ * review-state files there). The same run id check as {@link runRoot}.
135
135
  * @param {string} runId
136
136
  * @param {{root?: string}} [opts]
137
137
  * @returns {string}
138
+ * @throws {TypeError} a bad run id, or a `root` that is not absolute.
138
139
  */
139
- export function runRoot(runId, opts = {}) {
140
+ export function runRootPath(runId, opts = {}) {
140
141
  if (typeof runId !== 'string' || !RUN_ID.test(runId)) {
141
142
  throw new TypeError('tmp: runId must be one path segment of letters, digits, ".", "_" or "-"');
142
143
  }
144
+ return path.join(tmpBase(opts.root), runId);
145
+ }
146
+
147
+ /**
148
+ * Create (if needed) the run root `<base>/<runId>/`, record this process as its owner, and
149
+ * return its real absolute path.
150
+ * @param {string} runId
151
+ * @param {{root?: string}} [opts]
152
+ * @returns {string}
153
+ */
154
+ export function runRoot(runId, opts = {}) {
155
+ const dir = runRootPath(runId, opts);
143
156
  const base = tmpBase(opts.root);
144
- const dir = path.join(base, runId);
145
157
  // A parallel sweep that claimed a dead root of this name renames it away (see the claim rule)
146
158
  // between our mkdir and our write: the directory is then "missing" or the write fails with
147
159
  // ENOENT, and the retry creates a fresh directory in its place. A tombstone itself can never be
@@ -17,6 +17,13 @@
17
17
  * - `stopped` (`review_cap`, `l3_patch_exhausted`, `split_required`) ⇒ no session: the result
18
18
  * repeats the stop — only the human moves on (§4.11 rule 5). A `split_required` result (round 1
19
19
  * or a recheck stop) carries `tokens_in` and `budget` (B54).
20
+ * A state that does not load is refused with no session: `review-state-tampered` (a wrong file or
21
+ * anchor) or `review-state-lost` (the file is gone and its anchor has no `state_body`). A file
22
+ * that is gone but whose anchor carries the signed body is `restored` (B68): saved as a new seq,
23
+ * one signed `review.state_restored {block, file, seq, from_seq}` row, then the round runs as if
24
+ * it had loaded. B69: the state file lives under the code-forge home (`runs/<run>/review-state/`),
25
+ * not the temp root; a valid file still at the old temp path is moved there on its first load
26
+ * (status `ok`, no restored row).
20
27
  * The round's outcome: `approved: true` ONLY when the loop is `complete` AND its signed
21
28
  * `review.approved` row (keyed `run, block, file, content_hash` = the ticket's hash) was written;
22
29
  * else `findings` = the open fix list, `next` = what the orchestrator does (`fix` at `level`,
@@ -40,31 +47,79 @@
40
47
  * The review NEVER approves an empty or failed session: that is `unavailable` (§4.2 stub guard).
41
48
  * A throw inside the round is `unavailable` too, with the state saved as `next: retry` (see
42
49
  * `fixLoopRound`): the next ticket re-runs that round, never a fresh round 1.
50
+ * B62b: for a block the run record gives a worktree (`proof.isolation: worktree`) every content
51
+ * read — the file, its diff against the block base, the peers' diffs, the fix hunks and the
52
+ * budget's file set — happens in that tree, against the tree's `base_sha` (`contentRootFor`; the
53
+ * tree comes from the run record, never from the coder-writable ticket), and the packet's facts
54
+ * slot names the tree and its branch. A file changed only in the main tree is never seen. The
55
+ * base, the tree and the facts come from ONE read of the run record. A ticket is `unavailable`
56
+ * with one of these reasons:
57
+ * - `worktree-missing`: the recorded tree is gone;
58
+ * - `worktree-not-dir`: its path is not a directory;
59
+ * - `worktree-malformed`: no absolute path, a branch that is empty or has control characters, or
60
+ * a base_sha that is not a full hex id;
61
+ * - `worktree-branch-mismatch`: the tree is not on its recorded branch;
62
+ * - `worktree-foreign`: the tree belongs to another repository;
63
+ * - `worktree-workspace-missing`: the workspace's subdirectory is not in the tree;
64
+ * - `worktree-mismatch`: the tree the worker hashed the ticket in (`ctx.contentRoot`, from the
65
+ * ticket's `tree` or its absence) is not the record's tree;
66
+ * - `git-failed`: git could not be run on the tree;
67
+ * - `run-record-unreadable`: the record cannot be parsed, or names no workspace.
68
+ * A block without a worktree, or one the run record does not know, reads the main tree as before.
69
+ * B63a: a ticket is also `unavailable` with `context-unreadable` when the config's `context` key
70
+ * is bad or one of its rules or lessons files is missing, outside the repository or unreadable at
71
+ * review time; no session runs and the packet dir is removed. The worker does not retry it (the
72
+ * ticket is done); `review-file` on the same content is a fresh attempt every time (`retry: true`).
73
+ * The tree is passed as `contentRoot`: `repoRoot` stays the main tree, and the fix-loop state
74
+ * stays under the run root.
75
+ * B72: a verified `review.reopened {by: human}` row of this run for the file whose `at_seq` is the
76
+ * file's latest anchor seq (`reopenRowFor`, `review/reopen.mjs`) restarts a `stopped` file whose
77
+ * content changed since the stop, or a `lost` one: a new state (round 1, a FULL review with no
78
+ * `reviewed_content`, the level from the run record), saved at `at_seq + 1`, so the row works
79
+ * once; no `review.state_restored` row then. The block's L3 rung stays spent (it is read from the
80
+ * anchors). A row that does not verify, of another run, not `by: human`, at another seq, or a
81
+ * stopped file on the stop's own content changes nothing: the stop (or the lost refusal) repeats.
82
+ * Every stop records the ticket's content hash as the state's `stop_content_hash` (signed with
83
+ * the state), the content `block reopen` and this check compare against.
84
+ * B71: a ticket with `kind: 'answers'` (`review-file --answers`) runs no fix-loop round: after the
85
+ * same run-record, tree and mismatch checks it goes to `reviewAnswers` (`review/answers.mjs`) —
86
+ * one fresh L2 session that judges whether the answering file resolves the named open findings of
87
+ * the source, with signed `review.answered` rows for the resolved ones; the source's fix-loop
88
+ * state is neither read for a round nor saved.
43
89
  */
44
90
 
45
- import { rmSync } from 'node:fs';
91
+ import { realpathSync, rmSync } from 'node:fs';
46
92
  import path from 'node:path';
47
93
  import { blockKind } from '../decide/escalation.mjs';
48
94
  import { askJev } from '../decide/jev-client.mjs';
49
95
  import { planAndRecord, tierOf } from '../review/budget.mjs';
50
96
  import { budgetFor } from '../review/packet.mjs';
97
+ import { ContextError, reviewDigest } from '../context/pack.mjs';
51
98
  import { reviewFile, rulesRisk } from '../review/engine.mjs';
99
+ import { reviewAnswers } from '../review/answers.mjs';
52
100
  import { peerDiffReader } from '../review/moved.mjs';
53
101
  import { computeFileSet, ownsFile } from '../gates/scope.mjs';
54
102
  import { extraRoundsFor, newFileState, reopenForExtraRound, runRound } from '../review/fixloop.mjs';
103
+ import { reopenRowFor } from '../review/reopen.mjs';
55
104
  import { readRun } from '../state/run.mjs';
105
+ import { StateError } from '../state/paths.mjs';
106
+ import { blockTree, worktreeRecord } from '../proof/worktree.mjs';
56
107
  import { ledgerSchemaHistory } from '../review/schema-fallback.mjs';
57
108
  import { verifyRow } from '../state/signer.mjs';
58
109
  import { redact } from '../util/redact.mjs';
59
110
  import { keyedLock } from '../util/locks.mjs';
60
- import { blockAnchors, blockRungUsed, loadState, saveState } from './review-state.mjs';
61
- import { assertTicketId } from './ticket.mjs';
111
+ import { blockAnchors, blockRungUsed, loadState, RESTORED_EVENT, saveState } from './review-state.mjs';
112
+ import { assertAnswers } from './queue.mjs';
113
+ import { assertTicketId, contentHash, readRegularFileNoFollow, WorkerError } from './ticket.mjs';
62
114
 
63
115
  /**
64
116
  * @typedef {object} ReviewContext
65
- * @property {string} repoRoot
117
+ * @property {string} repoRoot - the main tree (the queue, the worker).
118
+ * @property {string} [contentRoot] - B62b: the tree the ticket's content is read in (the block's
119
+ * worktree); `repoRoot` when absent.
66
120
  * @property {string} runId
67
- * @property {string} runRootDir - the run's temp root (packets and session dirs live under it).
121
+ * @property {string} runRootDir - the run's temp root (packets and session dirs live under it; the
122
+ * review state no longer does — B69 — it is only read there once, to move an old file).
68
123
  * @property {Record<string, any>} cfg - the project config.
69
124
  * @property {string | null} jevKey - resolved once by the worker through B2's chain; never print.
70
125
  * @property {(opts: Record<string, any>) => Promise<Record<string, any>>} spawn - one isolated
@@ -86,6 +141,7 @@ import { assertTicketId } from './ticket.mjs';
86
141
  * @property {string} engine - `adaptive` or `consensus`.
87
142
  * @property {Array<Record<string, any>>} sessions - one summary per session run.
88
143
  * @property {string} [reason] - why it is not `reviewed` (stub-guard reason, `needs_file-refused`, …).
144
+ * @property {string} [message] - a refusal's human-readable explanation (`review-state-lost`).
89
145
  * @property {Array<Record<string, any>>} [findings] - the open fix list (`reviewed` / `stopped`).
90
146
  * @property {number} [round] - the fix-loop round this ticket ran (patch_check rounds not counted).
91
147
  * @property {string} [kind] - `full`, `recheck` or `patch_check`.
@@ -110,14 +166,17 @@ import { assertTicketId } from './ticket.mjs';
110
166
  * of its owned files. `ownedKinds` is the recorded `owned_kinds` (B57: which exact entries were
111
167
  * regular files at `block open`/`claim`), `{}` when there is none (every exact entry directory-like).
112
168
  * 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}>}
169
+ * `tree` (B62b) is the recorded `worktree` `{path, branch, base_sha}`, null when the block has
170
+ * none; `base` is then the tree's `base_sha`. A recorded worktree that `worktreeRecord` refuses
171
+ * (path, branch, hex base_sha) throws `worktree-malformed`.
172
+ * @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
173
  */
115
174
  export async function blockEntryFor(runId, block) {
116
175
  let record;
117
176
  try {
118
177
  record = await readRun(runId);
119
178
  } catch (err) {
120
- if (/** @type {any} */ (err)?.code === 'no-run') return { base: null, level: 'L2', owned: [], ownedKinds: {}, acceptance: undefined, kind: null };
179
+ if (/** @type {any} */ (err)?.code === 'no-run') return { base: null, level: 'L2', owned: [], ownedKinds: {}, acceptance: undefined, kind: null, tree: null, workspace: null };
121
180
  throw new Error('run-record-unreadable');
122
181
  }
123
182
  if (!record || typeof record !== 'object' || !record.blocks || typeof record.blocks !== 'object') throw new Error('run-record-unreadable');
@@ -128,7 +187,85 @@ export async function blockEntryFor(runId, block) {
128
187
  const owned = Array.isArray(entry.owned_files) ? entry.owned_files.filter((f) => typeof f === 'string') : [];
129
188
  const kinds = entry.owned_kinds;
130
189
  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 }) };
190
+ /** @type {WorktreeEntry | null} */
191
+ let tree = null;
192
+ if (entry.worktree !== undefined && entry.worktree !== null) {
193
+ try {
194
+ tree = worktreeRecord(entry.worktree);
195
+ } catch {
196
+ throw new Error('worktree-malformed');
197
+ }
198
+ }
199
+ 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 };
200
+ }
201
+
202
+ /** @typedef {{path: string, branch: string, base_sha: string}} WorktreeEntry */
203
+
204
+ /** The refusals of {@link blockTree} a ticket answers with, as its `reason`. */
205
+ const TREE_CODES = new Set(['worktree-malformed', 'worktree-missing', 'worktree-not-dir', 'worktree-branch-mismatch', 'worktree-workspace-missing', 'worktree-foreign', 'git-failed']);
206
+
207
+ /**
208
+ * B62b: where the content of a ticket of `block` is read — the top of the block's worktree from
209
+ * the run record (`blockTree`: present, a directory, on its recorded branch, of this repository),
210
+ * else `repoRoot`. `repoRoot` exactly as before B62b when there is no run record, the block is not
211
+ * in it, or it has no `worktree`: only a block WITH a worktree record can be refused here.
212
+ * @param {string} runId @param {string} block @param {string} repoRoot
213
+ * @returns {Promise<string>}
214
+ * @throws {WorkerError} a code of {@link TREE_CODES}, or `run-record-unreadable` for a record that
215
+ * cannot be parsed or is not a record; any other failure (an I/O error) is rethrown.
216
+ */
217
+ export async function contentRootFor(runId, block, repoRoot) {
218
+ let record;
219
+ try {
220
+ record = await readRun(runId);
221
+ } catch (err) {
222
+ if (/** @type {any} */ (err)?.code === 'no-run') return repoRoot;
223
+ if (err instanceof SyntaxError) throw new WorkerError('run-record-unreadable', `block ${block}: the run record cannot be read`);
224
+ throw err;
225
+ }
226
+ if (record === null || typeof record !== 'object') throw new WorkerError('run-record-unreadable', `block ${block}: the run record cannot be read`);
227
+ const blocks = record.blocks;
228
+ const entry = blocks && typeof blocks === 'object' && typeof block === 'string' && Object.hasOwn(blocks, block) ? blocks[block] : null;
229
+ if (entry?.worktree === undefined || entry.worktree === null) return repoRoot;
230
+ return treeTopOf(record.workspace, block, entry.worktree);
231
+ }
232
+
233
+ /**
234
+ * The realpath'd top of the recorded worktree `worktree` of `block` (`blockTree`).
235
+ * @param {string} workspace @param {string} block @param {unknown} worktree
236
+ * @returns {Promise<string>}
237
+ * @throws {WorkerError} a code of {@link TREE_CODES}; any other failure is rethrown.
238
+ */
239
+ async function treeTopOf(workspace, block, worktree) {
240
+ if (typeof workspace !== 'string' || !path.isAbsolute(workspace)) throw new WorkerError('run-record-unreadable', `block ${block}: the run record names no workspace`);
241
+ try {
242
+ return /** @type {import('../proof/worktree.mjs').BlockTree} */ (await blockTree(workspace, { worktree })).top;
243
+ } catch (err) {
244
+ const code = /** @type {any} */ (err)?.code;
245
+ if (err instanceof StateError && TREE_CODES.has(code)) throw new WorkerError(code, `block ${block}: ${err.message}`);
246
+ throw err;
247
+ }
248
+ }
249
+
250
+ /** @param {string} p @returns {string} `p` realpath'd, or `p` itself when it cannot be resolved. */
251
+ export function canonicalPath(p) {
252
+ try {
253
+ return realpathSync(p);
254
+ } catch {
255
+ return p;
256
+ }
257
+ }
258
+
259
+ /**
260
+ * The packet's facts slot: the acceptance clauses, and for a block with a worktree (B62b) a line
261
+ * naming the tree and its branch the file is reviewed in.
262
+ * @param {{acceptance?: unknown, tree: WorktreeEntry | null}} entry @param {Record<string, any> | undefined} cfg
263
+ * @returns {string}
264
+ */
265
+ function factsFor(entry, cfg) {
266
+ const excerpt = acceptanceExcerpt(entry.acceptance, cfg);
267
+ if (!entry.tree) return excerpt;
268
+ 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
269
  }
133
270
 
134
271
  /** The acceptance line written when the block's clauses cannot be read. */
@@ -213,15 +350,51 @@ export async function reviewTicket(ticket, ctx) {
213
350
  return { status: 'unavailable', reason: err instanceof Error ? err.message : 'run-record-unreadable', approved: false, engine: 'adaptive', sessions: [] };
214
351
  }
215
352
  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 });
353
+ // B62b: every content read of this ticket happens in the block's tree (the main tree without
354
+ // one), taken from the SAME entry as the base and the facts. The worker passes the tree it
355
+ // hashed the ticket in as `ctx.contentRoot`; a record that changed since names another tree.
356
+ let contentRoot;
357
+ try {
358
+ contentRoot = entry.tree ? await treeTopOf(entry.workspace, ticket.block, entry.tree) : ctx.repoRoot;
359
+ } catch (err) {
360
+ return { status: 'unavailable', reason: err instanceof WorkerError ? err.code : 'run-record-unreadable', approved: false, engine: 'adaptive', sessions: [] };
361
+ }
362
+ if (ctx.contentRoot !== undefined && canonicalPath(ctx.contentRoot) !== canonicalPath(contentRoot)) {
363
+ return { status: 'unavailable', reason: 'worktree-mismatch', approved: false, engine: 'adaptive', sessions: [] };
364
+ }
365
+ /** @type {ReviewContext} */
366
+ const tctx = { ...ctx, contentRoot };
367
+ if (ticket.kind === 'answers') {
368
+ // B71: an answers ticket is no review round: one answers session, the fix-loop state untouched
369
+ try {
370
+ return await reviewAnswers(ticket, tctx, { entry, workDir, io: { readFile: readRegularFileNoFollow, loadState, assertAnswers } });
371
+ } finally {
372
+ rmSync(workDir, { recursive: true, force: true });
373
+ }
374
+ }
375
+ const factsExcerpt = factsFor(entry, ctx.cfg);
376
+ const peerDiffs = blockPeerDiffs(contentRoot, { base: entry.base, owned: entry.owned, ownedKinds: entry.ownedKinds, file: ticket.file });
217
377
  try {
378
+ // B63a: the `context` rules and lessons, read from the MAIN tree (`ctx.repoRoot`, never the
379
+ // block's worktree) with the config in force for this ticket; '' without a `context` key, so
380
+ // the packet stays as it was. A bad `context` value or a rules/lessons file that is missing,
381
+ // outside the repository or unreadable makes the ticket `unavailable` (`context-unreadable`,
382
+ // re-queued by `review-file` on the same content, as often as asked): no review runs without the rules it was promised.
383
+ let rulesDigest;
384
+ try {
385
+ rulesDigest = reviewDigest({ repoRoot: ctx.repoRoot, cfg: ctx.cfg });
386
+ } catch (err) {
387
+ if (!(err instanceof ContextError)) throw err;
388
+ return { status: 'unavailable', reason: 'context-unreadable', approved: false, engine: 'adaptive', sessions: [] };
389
+ }
390
+ const digest = rulesDigest === '' ? {} : { rulesDigest };
218
391
  if (!ctx.key) {
219
392
  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 },
393
+ { repoRoot: contentRoot, file: ticket.file, base: entry.base, cfg: ctx.cfg, kind: entry.kind, workDir, factsExcerpt, ...digest, peerDiffs },
221
394
  { spawn: ctx.spawn, ...(ctx.writeRow ? { writeRow: ctx.writeRow } : {}) },
222
395
  );
223
396
  }
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 });
397
+ 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
398
  } finally {
226
399
  rmSync(workDir, { recursive: true, force: true });
227
400
  }
@@ -274,6 +447,9 @@ export function blockPeerDiffs(repoRoot, { base, owned, ownedKinds, file }) {
274
447
  */
275
448
  export const blockLockKey = (runId, block) => `block:${runId}\0${block}`;
276
449
 
450
+ /** B68: the `review-state-lost` refusal's message (the file is gone and the ledger has no body). */
451
+ export const LOST_MESSAGE = 'the review state file is gone and the ledger cannot rebuild it (its anchor carries no state_body); the file is not reviewed again from round 1 — the owner can run `code-forge block reopen`';
452
+
277
453
  /**
278
454
  * Whether the block's one L3 rung is taken: a verified anchor says so (`blockRungUsed`), OR another
279
455
  * file of the block has a saved state whose round decided `next: patch` — that round took the rung,
@@ -290,11 +466,12 @@ export function rungTaken(where) {
290
466
  for (const file of siblings) {
291
467
  let other;
292
468
  try {
293
- other = loadState({ ...where, file });
469
+ other = loadState({ ...where, file, migrate: false }); // B69: a sibling's old file is read, never moved here
294
470
  } catch {
295
471
  return { status: 'ok', used: true };
296
472
  }
297
- if (other.status !== 'ok' || other.state?.next?.action === 'patch') return { status: 'ok', used: true };
473
+ // a sibling that would be restored (B68) counts by its rebuilt state; lost or tampered fails closed
474
+ if ((other.status !== 'ok' && other.status !== 'restored') || other.state?.next?.action === 'patch') return { status: 'ok', used: true };
298
475
  }
299
476
  return rung;
300
477
  }
@@ -329,10 +506,10 @@ export function mayTakeRung(state, kind, cfg, rungUsed) {
329
506
  * first full round, or any round once the rung is gone) runs outside the block lock, in parallel
330
507
  * with the block's other files.
331
508
  * @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
509
+ * @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
510
  * @returns {Promise<ReviewOutcome>}
334
511
  */
335
- async function fixLoopRound(ticket, ctx, { base, level, owned, ownedKinds, blockKindOf, key, workDir, factsExcerpt, peerDiffs }) {
512
+ async function fixLoopRound(ticket, ctx, { base, level, owned, ownedKinds, blockKindOf, key, workDir, factsExcerpt, rulesDigest = '', peerDiffs }) {
336
513
  if (!ctx.readRows || !ctx.writeRow) return { status: 'unavailable', reason: 'no-ledger', approved: false, engine: 'adaptive', sessions: [] };
337
514
  const readRows = ctx.readRows;
338
515
  /** @type {{outcome: ReviewOutcome} | {round: () => Promise<ReviewOutcome>}} */
@@ -349,11 +526,34 @@ async function fixLoopRound(ticket, ctx, { base, level, owned, ownedKinds, block
349
526
  await recordBlockBudget(ticket, ctx, { base, owned, ownedKinds });
350
527
  }
351
528
  const where = { runRootDir: ctx.runRootDir, runId: ctx.runId, block: ticket.block, file: ticket.file, key, rows };
352
- const loaded = loadState(where);
529
+ const loaded = loadState({ ...where, contentRoot: ctx.contentRoot ?? ctx.repoRoot });
353
530
  const rung = rungTaken(where);
354
531
  if (loaded.status === 'tampered' || rung.status === 'tampered') return { outcome: /** @type {ReviewOutcome} */ ({ status: 'refused', reason: 'review-state-tampered', approved: false, engine: 'adaptive', sessions: [] }) };
532
+ // B72: the human's `block reopen` row for this file's latest anchor seq restarts a stopped
533
+ // (on changed content) or lost file: a new state, saved at that seq + 1 — the row works once
534
+ // (`rows`: this run's rows read above under the block lock; `key`: the run key from opts)
535
+ const reopenAt = {
536
+ runId: ctx.runId,
537
+ block: ticket.block,
538
+ file: ticket.file,
539
+ key,
540
+ status: loaded.status,
541
+ state: loaded.state,
542
+ seq: loaded.seq,
543
+ contentHash: ticket.content_hash,
544
+ };
545
+ const reopened = reopenRowFor(rows, reopenAt) !== null;
546
+ if (loaded.status === 'lost' && !reopened) return { outcome: /** @type {ReviewOutcome} */ ({ status: 'refused', reason: 'review-state-lost', message: LOST_MESSAGE, approved: false, engine: 'adaptive', sessions: [] }) };
547
+ let seq = loaded.seq;
548
+ if (loaded.status === 'restored' && loaded.state !== null && !reopened) {
549
+ // B68: the lost file is rebuilt from the anchor's signed body; saved as a new seq, then recorded
550
+ const writeRow = /** @type {(row: Record<string, any>) => Promise<unknown>} */ (ctx.writeRow);
551
+ await saveState({ ...where, state: loaded.state, seq, writeRow, contentRoot: ctx.contentRoot ?? ctx.repoRoot });
552
+ seq += 1;
553
+ await writeRow({ event: RESTORED_EVENT, block: ticket.block, file: ticket.file, seq, from_seq: loaded.seq });
554
+ }
355
555
  const rungUsed = rung.used;
356
- let state = loaded.state;
556
+ let state = reopened ? null : loaded.state;
357
557
  if (state === null || state.status === 'complete') state = newFileState({ file: ticket.file, level, l3RungUsed: rungUsed });
358
558
  state.l3_rung_used = state.l3_rung_used || rungUsed;
359
559
  // B47: an autopilot extra round raises the file's cap; a file stopped at the old cap reopens
@@ -372,7 +572,7 @@ async function fixLoopRound(ticket, ctx, { base, level, owned, ownedKinds, block
372
572
  state.l3_rung_used = true; // the orchestrator ran the block's one L3 patch before this ticket
373
573
  }
374
574
  const fixed = state;
375
- const round = () => playRound(ticket, ctx, { state: fixed, kind, where, seq: loaded.seq, base, blockKindOf, workDir, factsExcerpt, extraRounds, peerDiffs });
575
+ const round = () => playRound(ticket, ctx, { state: fixed, kind, where, seq, base, blockKindOf, workDir, factsExcerpt, rulesDigest, extraRounds, peerDiffs });
376
576
  // a round that could take the rung keeps the block lock until its state is saved
377
577
  if (mayTakeRung(fixed, kind, ctx.cfg, rungUsed)) return { outcome: await round() };
378
578
  return { round };
@@ -383,10 +583,10 @@ async function fixLoopRound(ticket, ctx, { base, level, owned, ownedKinds, block
383
583
  /**
384
584
  * Run the prepared round and save the file's state: the ticket's outcome.
385
585
  * @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
586
+ * @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
587
  * @returns {Promise<ReviewOutcome>}
388
588
  */
389
- async function playRound(ticket, ctx, { state, kind, where, seq, base, blockKindOf, workDir, factsExcerpt, extraRounds = 0, peerDiffs }) {
589
+ async function playRound(ticket, ctx, { state, kind, where, seq, base, blockKindOf, workDir, factsExcerpt, rulesDigest = '', extraRounds = 0, peerDiffs }) {
390
590
  /** @type {Record<string, any> | null} */
391
591
  let engineOutcome = null;
392
592
  let approvalWritten = false;
@@ -429,18 +629,19 @@ async function playRound(ticket, ctx, { state, kind, where, seq, base, blockKind
429
629
  await runRound(
430
630
  state,
431
631
  {
432
- repoRoot: ctx.repoRoot,
632
+ repoRoot: ctx.contentRoot ?? ctx.repoRoot,
433
633
  cfg: ctx.cfg,
434
634
  workDir,
435
635
  base,
436
636
  writeRow,
437
637
  factsExcerpt,
638
+ ...(rulesDigest !== '' ? { rulesDigest } : {}),
438
639
  extraRounds,
439
640
  ...(peerDiffs ? { peerDiffs } : {}),
440
641
  ...(jev ? { jev } : {}),
441
642
  ...(schemaHistory ? { schemaHistory } : {}),
442
643
  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 } : {}) });
644
+ 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
645
  return engineOutcome;
445
646
  },
446
647
  spawn: async (opts) => {
@@ -466,7 +667,12 @@ async function playRound(ticket, ctx, { state, kind, where, seq, base, blockKind
466
667
  state.pending_kind = kind;
467
668
  }
468
669
  }
469
- await saveState({ ...where, state, seq, writeRow: /** @type {(row: Record<string, any>) => Promise<unknown>} */ (ctx.writeRow) });
670
+ // B72: a stop (any reason) records the content it reviewed — the ticket's — in the signed state,
671
+ // so `block reopen` and the worker know which content this stop saw (`stopContentHash`)
672
+ const stamped = /** @type {Record<string, any>} */ (state);
673
+ if (state.status === 'stopped') stamped.stop_content_hash = ticket.content_hash;
674
+ else delete stamped.stop_content_hash;
675
+ await saveState({ ...where, state, seq, writeRow: /** @type {(row: Record<string, any>) => Promise<unknown>} */ (ctx.writeRow), contentRoot: ctx.contentRoot ?? ctx.repoRoot });
470
676
 
471
677
  const eng = /** @type {Record<string, any> | null} */ (engineOutcome);
472
678
  const head = { ...loopFields(state, kind), engine: eng?.engine ?? 'adaptive', sessions: eng?.sessions ?? rechecks };
@@ -500,7 +706,7 @@ async function recordBlockBudget(ticket, ctx, { base, owned, ownedKinds }) {
500
706
  let changed = [];
501
707
  if (base !== null && owned.length > 0) {
502
708
  try {
503
- changed = (await computeFileSet({ cwd: ctx.repoRoot, base })).all.filter((f) => ownsFile(owned, f, ownedKinds));
709
+ changed = (await computeFileSet({ cwd: ctx.contentRoot ?? ctx.repoRoot, base })).all.filter((f) => ownsFile(owned, f, ownedKinds));
504
710
  } catch {
505
711
  // no row on a git failure: a ticket-only forecast would be permanent (the row is written
506
712
  // 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,
@@ -316,6 +318,7 @@ export async function createWorker(opts, deps = {}) {
316
318
  approved: outcome.approved === true,
317
319
  engine: outcome.engine,
318
320
  ...(typeof outcome.reason === 'string' ? { reason: outcome.reason } : {}),
321
+ ...(typeof outcome.message === 'string' ? { message: outcome.message } : {}),
319
322
  ...(Array.isArray(outcome.findings) ? { findings: outcome.findings } : {}),
320
323
  ...packetSize(outcome),
321
324
  ...loopResult(outcome),
@@ -360,24 +363,42 @@ export async function createWorker(opts, deps = {}) {
360
363
  */
361
364
  async function reviewOne(id, ticket) {
362
365
  const base = { event: 'review.result', block: ticket.block, file: ticket.file, content_hash: ticket.content_hash };
363
- /** @type {string | null} */
366
+ if (ticket.run !== runId) return { ...base, status: 'refused', reason: 'other-run', approved: false };
367
+ // B62b: the tree the content is hashed AND reviewed in — a block's worktree (from the run
368
+ // record, as review-file did), else the main tree. A tree that cannot be used (gone, malformed)
369
+ // or a block entry that cannot be read is `unavailable` with its code: never approval, and a
370
+ // later ticket for the same content runs again once it is fixed.
371
+ let contentRoot;
372
+ try {
373
+ contentRoot = await contentRootFor(runId, ticket.block, repoRoot);
374
+ } catch (err) {
375
+ // only the codes contentRootFor answers with; an I/O failure goes to runOne (`worker-error`)
376
+ if (!(err instanceof WorkerError)) throw err;
377
+ return { ...base, status: 'unavailable', reason: err.code, approved: false };
378
+ }
379
+ // a ticket was hashed in the tree it names (the main tree when it names none): never review it
380
+ // in another one — also a ticket with no tree for a block the record gives a worktree
381
+ const hashedIn = typeof ticket.tree === 'string' ? ticket.tree : repoRoot;
382
+ if (canonicalPath(hashedIn) !== canonicalPath(contentRoot)) return { ...base, status: 'unavailable', reason: 'worktree-mismatch', approved: false };
383
+ /** @type {string} */
364
384
  let current;
365
385
  try {
366
- current = ticket.run === runId ? contentHash(repoRoot, ticket.file) : null;
386
+ current = contentHash(contentRoot, ticket.file);
367
387
  } catch (err) {
368
388
  // 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 };
389
+ const reason = err instanceof WorkerError ? err.code : 'unreadable';
390
+ return { ...base, status: 'refused', reason, approved: false };
370
391
  }
371
- if (ticket.run !== runId) return { ...base, status: 'refused', reason: 'other-run', approved: false };
372
392
  if (current === DELETED_HASH && ticket.content_hash !== DELETED_HASH) return { ...base, status: 'stale', reason: 'missing-file', approved: false };
373
- if (current !== ticket.content_hash) return { ...base, status: 'stale', approved: false };
393
+ // B71: an answers ticket names why (its source moved); a review ticket keeps no reason
394
+ if (current !== ticket.content_hash) return { ...base, status: 'stale', ...(ticket.kind === 'answers' ? { reason: 'source-changed' } : {}), approved: false };
374
395
  let ticketCfg;
375
396
  try {
376
397
  ticketCfg = await configFor(id);
377
398
  } catch {
378
399
  return { ...base, status: 'unavailable', reason: 'config-snapshot', approved: false };
379
400
  }
380
- return reviewWith(ticket, base, ticketCfg);
401
+ return reviewWith(ticket, base, ticketCfg, contentRoot);
381
402
  }
382
403
 
383
404
  /**