@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
@@ -3,48 +3,149 @@
3
3
  *
4
4
  * B12b's `fixloop.mjs` is a state machine whose `FileState` (round, level, open findings,
5
5
  * `reviewed_content`, …) must survive between two `review-file` calls on the same file: round 1
6
- * is the first ticket, round 2 (the fix-hunk recheck) the next one, and so on. The worker keeps it
7
- * in the run's temp root, OUTSIDE the workspace:
6
+ * is the first ticket, round 2 (the fix-hunk recheck) the next one, and so on. B69: the worker
7
+ * keeps it in the run's own directory under the code-forge home, OUTSIDE the workspace and outside
8
+ * the temp root (a temp sweep or a TMPDIR cleanup no longer loses it):
8
9
  *
9
- * <run-root>/review-state/<block>/<sha256(file)[0:24]>.json
10
+ * <code-forge home>/runs/<run>/review-state/<block>/<sha256(file)[0:24]>.json ({@link statePath})
11
+ *
12
+ * The old place, `<run temp root>/review-state/<block>/<name>.json` ({@link legacyStatePath}), is
13
+ * read once: a file missing at the new path but present at the old one that passes the same
14
+ * checks is moved (new written atomically 0600, old removed); a wrong one there is `tampered` and
15
+ * left where it is.
10
16
  *
11
17
  * Every state file is SIGNED with the run key (`{run, block, file, seq, state}` + `mac`) AND
12
18
  * anchored in the run's append-only ledger: each save first writes a signed row
13
19
  * `{event: 'review.state', block, file, seq, state_sha, l3_rung_used}`. A load matches the file
14
- * against the LATEST verified anchor (highest `seq`) for the file: a missing file, an older signed
15
- * file (rollback), a different sha, or a file with no anchor is `tampered` — the hook refuses the
16
- * ticket, never a fresh round 1 that could clear a `review_cap` stop or an open finding. A
20
+ * against the LATEST verified anchor (highest `seq`) for the file: an older signed file
21
+ * (rollback), a different sha, or a file with no anchor is `tampered` — the hook refuses the
22
+ * ticket, never a fresh round 1 that could clear a `review_cap` stop or an open finding. B68: the
23
+ * anchor also carries `state_body`, the signed state with `reviewed_content` set to null and its
24
+ * sha carried as `reviewed_content_sha` (or `state_omitted: "too_large"` over `STATE_BODY_CAP`), so a MISSING file is `restored` from it
25
+ * (the engine saves it as a new seq and writes `review.state_restored`); an anchor with no body
26
+ * makes a missing file `lost` (refused, never a round 1). A
17
27
  * `review.state` row of the run whose MAC does not verify is `tampered` for every block and file
18
28
  * of it (`blockAnchors`). Whether the block's one L3 rung was used comes from the anchors, never
19
29
  * from the state files.
30
+ *
31
+ * B78: the reviewed text itself is kept as a git blob in the content root's object store
32
+ * (`git hash-object -w --no-filters --stdin`), its id in the signed body as `reviewed_content_blob`.
33
+ * A restore reads it back (`git cat-file blob`) and uses it only when its sha256 equals
34
+ * `reviewed_content_sha`; a missing, pruned or mismatching blob falls back to the B68 rule. A blob
35
+ * write that fails saves `reviewed_content_blob: null` and never fails the round.
20
36
  */
21
37
 
38
+ import { spawnSync } from 'node:child_process';
22
39
  import { createHash, randomBytes } from 'node:crypto';
23
- import { mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs';
40
+ import { existsSync, linkSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs';
24
41
  import path from 'node:path';
42
+ import { exec } from '../util/exec.mjs';
43
+ import { assertRunId, runsDir } from '../state/paths.mjs';
25
44
  import { signRow, verifyRow } from '../state/signer.mjs';
26
- import { assertBlockId, assertRowPath } from './ticket.mjs';
45
+ // B78: every blob `git` call runs with `gitChildEnv()`: inherited GIT_* removed, no system config,
46
+ // global config = /dev/null
47
+ import { assertBlockId, assertRowPath, gitChildEnv } from './ticket.mjs';
27
48
 
28
49
  /** @typedef {import('../review/fixloop.mjs').FileState} FileState */
29
50
  /** @typedef {{runRootDir: string, runId: string, block: string, file: string, key: Buffer, rows: Array<Record<string, any>>}} Where */
30
51
 
31
52
  export const STATE_EVENT = 'review.state';
53
+ /** B68: the row the engine writes after rebuilding a lost state file from its anchor. */
54
+ export const RESTORED_EVENT = 'review.state_restored';
55
+ /** B68: an anchor's `state_body` over this many bytes (as JSON) is `state_omitted: "too_large"`. */
56
+ export const STATE_BODY_CAP = 64 * 1024;
57
+
58
+ /**
59
+ * B69: the block's state directory, `<code-forge home>/runs/<run>/review-state/<block>` (the run's
60
+ * own directory, next to `runs/<run>/blocks/`).
61
+ * @param {string} runId @param {string} block @returns {string}
62
+ */
63
+ export const blockStateDir = (runId, block) => path.join(runsDir(), assertRunId(runId), 'review-state', assertBlockId(block));
64
+
65
+ /** @param {string} file @returns {string} the state file's name. */
66
+ const stateName = (file) => `${createHash('sha256').update(assertRowPath(file)).digest('hex').slice(0, 24)}.json`;
32
67
 
33
- /** @param {string} runRootDir @param {string} block @returns {string} */
34
- export const blockStateDir = (runRootDir, block) => path.join(runRootDir, 'review-state', assertBlockId(block));
68
+ /**
69
+ * The ONE path every reader and writer of a file's review state uses (B69).
70
+ * @param {string} runId @param {string} block @param {string} file - repo-root-relative.
71
+ * @returns {string}
72
+ */
73
+ export function statePath(runId, block, file) {
74
+ return path.join(blockStateDir(runId, block), stateName(file));
75
+ }
35
76
 
36
77
  /**
37
- * @param {string} runRootDir @param {string} block @param {string} file - repo-root-relative.
78
+ * B69: where the state file lived before (the run's temp root) — read once, to move it.
79
+ * @param {string} runRootDir @param {string} block @param {string} file
38
80
  * @returns {string}
39
81
  */
40
- export function statePath(runRootDir, block, file) {
41
- const name = createHash('sha256').update(assertRowPath(file)).digest('hex').slice(0, 24);
42
- return path.join(blockStateDir(runRootDir, block), `${name}.json`);
82
+ export function legacyStatePath(runRootDir, block, file) {
83
+ return path.join(runRootDir, 'review-state', assertBlockId(block), stateName(file));
43
84
  }
44
85
 
45
86
  /** @param {string} text @returns {string} */
46
87
  const sha = (text) => createHash('sha256').update(text).digest('hex');
47
88
 
89
+ /**
90
+ * B78: the env of a blob `git` call in `contentRoot`: `gitChildEnv()` plus a discovery ceiling at
91
+ * the root's parent, so a content root that is not itself a repository never reaches (and writes
92
+ * into) an enclosing one.
93
+ * @param {string} contentRoot @returns {NodeJS.ProcessEnv}
94
+ */
95
+ const blobEnv = (contentRoot) => ({ ...gitChildEnv(), GIT_CEILING_DIRECTORIES: path.dirname(path.resolve(contentRoot)) });
96
+
97
+ /** B78: a git object id (SHA-1 or SHA-256 repository), lowercase hex only. */
98
+ const OBJECT_ID = /^(?:[0-9a-f]{40}|[0-9a-f]{64})$/;
99
+ /** B78: how long one blob write or read may take before it counts as failed. */
100
+ const BLOB_TIMEOUT_MS = 10_000;
101
+
102
+ /**
103
+ * B78: write `text` (its UTF-8 bytes, no filters) as a blob into `contentRoot`'s object store.
104
+ * @param {string} contentRoot @param {string} text
105
+ * @returns {Promise<string | null>} the blob id, or null on any failure (never throws).
106
+ */
107
+ async function writeBlob(contentRoot, text) {
108
+ try {
109
+ const res = await exec(['git', 'hash-object', '-w', '--no-filters', '--stdin'], {
110
+ cwd: contentRoot,
111
+ env: blobEnv(contentRoot),
112
+ input: Buffer.from(text, 'utf8'),
113
+ timeoutMs: BLOB_TIMEOUT_MS,
114
+ });
115
+ const id = res.stdout.trim();
116
+ return res.result === 'ok' && OBJECT_ID.test(id) ? id : null;
117
+ } catch {
118
+ return null;
119
+ }
120
+ }
121
+
122
+ /**
123
+ * B78: the blob's text when it is in `contentRoot`'s object store and its sha256 is `wanted`.
124
+ * Synchronous (the load is), so it spawns with `spawnSync`: argv only, no shell, a timeout.
125
+ * @param {string} contentRoot @param {unknown} id @param {string} wanted
126
+ * @returns {string | null} null when the id is not an object id, the blob is unreadable or gone,
127
+ * or its text does not match `wanted` (a mismatching blob is never used).
128
+ */
129
+ function readBlob(contentRoot, id, wanted) {
130
+ if (typeof id !== 'string' || !OBJECT_ID.test(id)) return null;
131
+ try {
132
+ const res = spawnSync('git', ['cat-file', 'blob', id], {
133
+ cwd: contentRoot,
134
+ env: blobEnv(contentRoot),
135
+ shell: false,
136
+ stdio: ['ignore', 'pipe', 'pipe'],
137
+ timeout: BLOB_TIMEOUT_MS,
138
+ killSignal: 'SIGKILL',
139
+ maxBuffer: 64 * 1024 * 1024,
140
+ });
141
+ if (res.error || res.status !== 0 || !Buffer.isBuffer(res.stdout)) return null;
142
+ const text = res.stdout.toString('utf8');
143
+ return sha(text) === wanted ? text : null;
144
+ } catch {
145
+ return null;
146
+ }
147
+ }
148
+
48
149
  /**
49
150
  * The block's verified `review.state` anchors, or null when any anchor of the RUN fails: EVERY
50
151
  * `review.state` row of the run is verified (MAC, integer `seq`, string `state_sha`) BEFORE the
@@ -73,64 +174,258 @@ function latestAnchor(anchors, file) {
73
174
  }
74
175
 
75
176
  /**
76
- * @param {Where} where
77
- * @returns {{status: 'none' | 'ok' | 'tampered', state: FileState | null, seq: number}}
177
+ * Whether a signed body (a state file's, or an anchor's `state_body`) is this file's state at the
178
+ * anchor's seq: its MAC verifies with the run key, its run/block/file/seq equal the anchor's, and
179
+ * `state.file === file`.
180
+ * @param {any} body @param {{runId: string, block: string, file: string, key: Buffer, seq: number}} at
181
+ * @returns {boolean}
78
182
  */
79
- export function loadState({ runRootDir, runId, block, file, key, rows }) {
80
- const anchors = blockAnchors({ runId, block, key, rows });
81
- if (anchors === null) return { status: 'tampered', state: null, seq: 0 };
82
- const anchor = latestAnchor(anchors, file);
83
- let text = null;
183
+ function bodyMatches(body, { runId, block, file, key, seq }) {
184
+ return Boolean(
185
+ body &&
186
+ typeof body === 'object' &&
187
+ verifyRow(body, key).ok &&
188
+ body.run === runId &&
189
+ body.block === block &&
190
+ body.file === file &&
191
+ body.seq === seq &&
192
+ body.state &&
193
+ typeof body.state === 'object' &&
194
+ body.state.file === file,
195
+ );
196
+ }
197
+
198
+ /**
199
+ * B68: rebuild a lost state file from its latest anchor's signed `state_body`. `reviewed_content`
200
+ * is (B78) the body's `reviewed_content_blob` read from the content root's object store when its
201
+ * sha equals `reviewed_content_sha`; else the file's current working-tree content when its sha
202
+ * equals it; else null (the next round then reviews the full file).
203
+ * @param {Record<string, any>} anchor
204
+ * @param {{runId: string, block: string, file: string, key: Buffer, contentRoot?: string}} at
205
+ * @returns {{status: 'restored' | 'lost' | 'tampered', state: FileState | null, seq: number}}
206
+ */
207
+ function restoreFromAnchor(anchor, { runId, block, file, key, contentRoot }) {
208
+ if (!Object.hasOwn(anchor, 'state_body')) return { status: 'lost', state: null, seq: anchor.seq };
209
+ const body = anchor.state_body;
210
+ if (!bodyMatches(body, { runId, block, file, key, seq: anchor.seq })) return { status: 'tampered', state: null, seq: anchor.seq };
211
+ const wanted = body.reviewed_content_sha;
212
+ /** @type {string | null} */
213
+ let reviewed = null;
214
+ if (typeof wanted === 'string' && typeof contentRoot === 'string') reviewed = readBlob(contentRoot, body.reviewed_content_blob, wanted);
215
+ if (reviewed === null && typeof wanted === 'string' && typeof contentRoot === 'string') {
216
+ try {
217
+ const current = readFileSync(path.join(contentRoot, file), 'utf8');
218
+ if (sha(current) === wanted) reviewed = current;
219
+ } catch {
220
+ // unreadable now: reviewed stays null, the next round reviews the full file
221
+ }
222
+ }
223
+ const state = /** @type {FileState} */ ({ ...body.state, reviewed_content: reviewed });
224
+ return { status: 'restored', state, seq: anchor.seq };
225
+ }
226
+
227
+ /**
228
+ * @param {string} file @returns {string | null} the file's text, null when it does not exist.
229
+ * @throws any read error but ENOENT.
230
+ */
231
+ function readIfThere(file) {
84
232
  try {
85
- text = readFileSync(statePath(runRootDir, block, file), 'utf8');
233
+ return readFileSync(file, 'utf8');
86
234
  } catch (err) {
87
- if (/** @type {NodeJS.ErrnoException} */ (err).code !== 'ENOENT') return { status: 'tampered', state: null, seq: 0 };
235
+ if (/** @type {NodeJS.ErrnoException} */ (err).code === 'ENOENT') return null;
236
+ throw err;
88
237
  }
89
- if (anchor === null) return text === null ? { status: 'none', state: null, seq: 0 } : { status: 'tampered', state: null, seq: 0 };
90
- if (text === null || sha(text) !== anchor.state_sha) return { status: 'tampered', state: null, seq: anchor.seq };
238
+ }
239
+
240
+ /**
241
+ * Replace `target` with `text` atomically (file mode 0600, a new dir 0700).
242
+ * @param {string} target @param {string} text
243
+ */
244
+ function writeAtomic(target, text) {
245
+ mkdirSync(path.dirname(target), { recursive: true, mode: 0o700 });
246
+ const tmp = `${target}.${process.pid}.${randomBytes(4).toString('hex')}.tmp`;
247
+ try {
248
+ writeFileSync(tmp, text, { mode: 0o600 });
249
+ renameSync(tmp, target);
250
+ } finally {
251
+ try {
252
+ rmSync(tmp, { force: true });
253
+ } catch {
254
+ // a stray temp file never hides the write's own error
255
+ }
256
+ }
257
+ }
258
+
259
+ /**
260
+ * A state file's text checked against the file's latest anchor.
261
+ * @param {string} text @param {Record<string, any>} anchor @param {{runId: string, block: string, file: string, key: Buffer}} at
262
+ * @returns {{status: 'ok' | 'tampered', state: FileState | null, seq: number}}
263
+ */
264
+ function checkText(text, anchor, { runId, block, file, key }) {
265
+ if (sha(text) !== anchor.state_sha) return { status: 'tampered', state: null, seq: anchor.seq };
91
266
  let body = null;
92
267
  try {
93
268
  body = JSON.parse(text);
94
269
  } catch {
95
270
  body = null;
96
271
  }
97
- const ok =
98
- body &&
99
- typeof body === 'object' &&
100
- verifyRow(body, key).ok &&
101
- body.run === runId &&
102
- body.block === block &&
103
- body.file === file &&
104
- body.seq === anchor.seq &&
105
- body.state &&
106
- typeof body.state === 'object' &&
107
- body.state.file === file;
108
- if (!ok) return { status: 'tampered', state: null, seq: anchor.seq };
272
+ if (!bodyMatches(body, { runId, block, file, key, seq: anchor.seq })) return { status: 'tampered', state: null, seq: anchor.seq };
109
273
  return { status: 'ok', state: /** @type {FileState} */ (body.state), seq: anchor.seq };
110
274
  }
111
275
 
112
276
  /**
113
- * Anchor, then write: the signed `review.state` row goes to the ledger FIRST (a failed ledger write
114
- * changes nothing and throws), then the state file is replaced atomically (mode 0600).
115
- * @param {Where & {state: FileState, seq: number, writeRow: (row: Record<string, any>) => Promise<unknown>}} opts
116
- * `seq` - the loaded state's seq (0 for a new file); this save is `seq + 1`.
277
+ * @param {Omit<Where, 'runRootDir'> & {runRootDir?: string, contentRoot?: string, migrate?: boolean, afterMiss?: () => void}} where -
278
+ * `contentRoot`: the tree the file's current content is read from when a lost state is restored
279
+ * (B68); without it the restored `reviewed_content` is null. `runRootDir`: the run's temp root,
280
+ * where an old state file may still be (B69). `migrate` (default true): move a valid old file to
281
+ * the new path; false only reads it (a sibling's check never moves another ticket's file).
282
+ * `afterMiss`: a test seam called when both paths were missing, before the new path is read
283
+ * once more.
284
+ * @returns {{status: 'none' | 'ok' | 'restored' | 'lost' | 'tampered', state: FileState | null, seq: number}}
117
285
  */
118
- export async function saveState({ runRootDir, runId, block, file, key, state, seq, writeRow }) {
119
- const target = statePath(runRootDir, block, file);
120
- const next = seq + 1;
121
- const text = `${JSON.stringify(signRow({ run: runId, block, file, seq: next, state }, key))}\n`;
122
- await writeRow({ event: STATE_EVENT, block, file, seq: next, state_sha: sha(text), l3_rung_used: state.l3_rung_used === true });
123
- mkdirSync(path.dirname(target), { recursive: true, mode: 0o700 });
124
- const tmp = `${target}.${process.pid}.${randomBytes(4).toString('hex')}.tmp`;
286
+ export function loadState({ runRootDir, runId, block, file, key, rows, contentRoot, migrate = true, afterMiss }) {
287
+ const anchors = blockAnchors({ runId, block, key, rows });
288
+ if (anchors === null) return { status: 'tampered', state: null, seq: 0 };
289
+ const anchor = latestAnchor(anchors, file);
290
+ /** @type {string} */
291
+ let target;
292
+ let text = null;
293
+ /** @type {string | null} */
294
+ let legacy = null;
125
295
  try {
296
+ target = statePath(runId, block, file);
297
+ text = readIfThere(target);
298
+ if (text === null && typeof runRootDir === 'string') {
299
+ legacy = legacyStatePath(runRootDir, block, file);
300
+ text = readIfThere(legacy);
301
+ if (text === null) {
302
+ legacy = null;
303
+ // another ticket may have moved the old file to the new path between the two reads
304
+ afterMiss?.();
305
+ text = readIfThere(target);
306
+ }
307
+ }
308
+ } catch {
309
+ return { status: 'tampered', state: null, seq: 0 };
310
+ }
311
+ if (anchor === null) return text === null ? { status: 'none', state: null, seq: 0 } : { status: 'tampered', state: null, seq: 0 };
312
+ if (text === null) return restoreFromAnchor(anchor, { runId, block, file, key, ...(contentRoot !== undefined ? { contentRoot } : {}) });
313
+ const checked = checkText(text, anchor, { runId, block, file, key });
314
+ if (checked.status !== 'ok' || legacy === null || !migrate) return checked;
315
+ // B69: a valid file at the old temp path moves to the new one — never over a file already there
316
+ // (a ticket that saved meanwhile wins and is loaded as usual) — then the old one is removed. A
317
+ // move that fails leaves the old file where it is; the load is `ok` either way (no throw)
318
+ const moved = moveIn(target, text);
319
+ if (moved === 'exists') {
320
+ const now = loadState({ runId, block, file, key, rows, ...(contentRoot !== undefined ? { contentRoot } : {}) });
321
+ if (now.status !== 'ok') return now;
322
+ // the new path holds a valid state (another ticket's move or save): the old copy is done
323
+ try {
324
+ rmSync(legacy, { force: true });
325
+ } catch {
326
+ // the old copy stays; the new path is read first from now on
327
+ }
328
+ return now;
329
+ }
330
+ if (moved === 'moved') {
331
+ try {
332
+ rmSync(legacy, { force: true });
333
+ } catch {
334
+ // the old copy stays; the new path is read first from now on
335
+ }
336
+ }
337
+ return checked;
338
+ }
339
+
340
+ /**
341
+ * B69: write `text` to the new state path without overwriting a file there: a hard link of a
342
+ * 0600 temp file, or — where hard links fail (EPERM, ENOTSUP, …) — an exclusive create.
343
+ * @param {string} target @param {string} text
344
+ * @returns {'moved' | 'exists' | 'failed'}
345
+ */
346
+ function moveIn(target, text) {
347
+ let tmp = null;
348
+ try {
349
+ mkdirSync(path.dirname(target), { recursive: true, mode: 0o700 });
350
+ tmp = `${target}.${process.pid}.${randomBytes(4).toString('hex')}.tmp`;
126
351
  writeFileSync(tmp, text, { mode: 0o600 });
127
- renameSync(tmp, target);
352
+ try {
353
+ linkSync(tmp, target);
354
+ return 'moved';
355
+ } catch (err) {
356
+ if (/** @type {NodeJS.ErrnoException} */ (err).code === 'EEXIST') return 'exists';
357
+ }
358
+ writeFileSync(target, text, { flag: 'wx', mode: 0o600 });
359
+ return 'moved';
128
360
  } catch (err) {
129
- rmSync(tmp, { force: true });
130
- throw err;
361
+ return /** @type {NodeJS.ErrnoException} */ (err)?.code === 'EEXIST' ? 'exists' : 'failed';
362
+ } finally {
363
+ if (tmp !== null) {
364
+ try {
365
+ rmSync(tmp, { force: true });
366
+ } catch {
367
+ // a stray temp file never fails the load
368
+ }
369
+ }
131
370
  }
132
371
  }
133
372
 
373
+ /**
374
+ * B69 (doctor): the files of the run whose latest verified `review.state` anchor has a state file
375
+ * neither at the new path nor (when `runRootDir` is given) at the old one, each with whether that
376
+ * anchor carries a `state_body` (the ledger can rebuild it) or not (lost). The anchor is picked
377
+ * exactly as `loadState` picks it (`blockAnchors` per block, then `latestAnchor`).
378
+ * @param {{runId: string, key: Buffer, rows: Array<Record<string, any>>, runRootDir?: string}} opts
379
+ * @returns {Array<{block: string, file: string, rebuildable: boolean}> | null} null when an anchor
380
+ * of the run does not verify (every ticket of the run is then refused `review-state-tampered`).
381
+ */
382
+ export function missingStateFiles({ runId, key, rows, runRootDir }) {
383
+ const blocks = [...new Set(rows.filter((r) => r?.event === STATE_EVENT && r.run === runId).map((r) => r.block))];
384
+ const out = [];
385
+ for (const block of blocks) {
386
+ const anchors = blockAnchors({ runId, block, key, rows });
387
+ if (anchors === null) return null;
388
+ for (const file of new Set(anchors.map((a) => a.file))) {
389
+ const anchor = latestAnchor(anchors, file);
390
+ if (anchor === null) continue;
391
+ let paths;
392
+ try {
393
+ paths = [statePath(runId, block, file), ...(typeof runRootDir === 'string' ? [legacyStatePath(runRootDir, block, file)] : [])];
394
+ } catch {
395
+ continue; // a block id or a path no state file can have
396
+ }
397
+ if (paths.some((p) => existsSync(p))) continue;
398
+ out.push({ block, file, rebuildable: Object.hasOwn(anchor, 'state_body') });
399
+ }
400
+ }
401
+ return out;
402
+ }
403
+
404
+ /**
405
+ * Anchor, then write: the signed `review.state` row goes to the ledger FIRST (a failed ledger write
406
+ * changes nothing and throws), then the state file is replaced atomically (mode 0600).
407
+ * @param {Where & {state: FileState, seq: number, writeRow: (row: Record<string, any>) => Promise<unknown>, contentRoot?: string}} opts
408
+ * `seq` - the loaded state's seq (0 for a new file); this save is `seq + 1`. `contentRoot` (B78):
409
+ * the tree the round read the file in; the reviewed text goes into its object store as a blob.
410
+ */
411
+ export async function saveState({ runId, block, file, key, state, seq, writeRow, contentRoot }) {
412
+ const target = statePath(runId, block, file);
413
+ const next = seq + 1;
414
+ const text = `${JSON.stringify(signRow({ run: runId, block, file, seq: next, state }, key))}\n`;
415
+ // B68: the slim signed state rides on the anchor (its own MAC covers it), so a lost file can be
416
+ // rebuilt; the full reviewed text stays out of the ledger, only its sha (and, B78, the id of its
417
+ // git blob in the content root's object store) goes in
418
+ const reviewed = typeof state.reviewed_content === 'string' ? state.reviewed_content : null;
419
+ const blob = reviewed !== null && typeof contentRoot === 'string' ? await writeBlob(contentRoot, reviewed) : null;
420
+ const stateBody = signRow(
421
+ { run: runId, block, file, seq: next, state: { ...state, reviewed_content: null }, reviewed_content_sha: reviewed === null ? null : sha(reviewed), reviewed_content_blob: blob },
422
+ key,
423
+ );
424
+ const slim = Buffer.byteLength(JSON.stringify(stateBody), 'utf8') > STATE_BODY_CAP ? { state_omitted: 'too_large' } : { state_body: stateBody };
425
+ await writeRow({ event: STATE_EVENT, block, file, seq: next, state_sha: sha(text), l3_rung_used: state.l3_rung_used === true, ...slim });
426
+ writeAtomic(target, text);
427
+ }
428
+
134
429
  /**
135
430
  * Whether the block's one L3 rung (§3.6 rules 5/6) was already used on ANY of its files — the
136
431
  * rung is once per block. Derived from the ledger anchors only; fails closed: a block anchor that
@@ -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
  }