@codedology/code-forge 0.7.7 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (59) hide show
  1. package/CHANGELOG.md +41 -0
  2. package/README.md +50 -2
  3. package/docs/README.md +2 -1
  4. package/docs/reference/blocks.json +157 -1
  5. package/docs/reference/config.md +26 -8
  6. package/package.json +1 -1
  7. package/schema/code-forge.schema.json +81 -4
  8. package/skill/SKILL.md +6 -6
  9. package/skill/references/code.md +33 -4
  10. package/skill/references/ledger.md +8 -4
  11. package/skill/references/proof.md +44 -3
  12. package/skill/references/review.md +4 -2
  13. package/skill/references/security.md +1 -1
  14. package/skill/scripts/forge +1 -1
  15. package/skill/templates/coder-brief.md +18 -1
  16. package/src/autopilot/binnacle.mjs +21 -3
  17. package/src/cli/block.mjs +292 -33
  18. package/src/cli/context.mjs +175 -0
  19. package/src/cli/gates.mjs +125 -15
  20. package/src/cli/ledger.mjs +46 -2
  21. package/src/cli/proof.mjs +62 -16
  22. package/src/cli/report.mjs +7 -4
  23. package/src/cli/review-file.mjs +61 -6
  24. package/src/cli/status.mjs +212 -0
  25. package/src/config/validate.mjs +45 -11
  26. package/src/context/pack.mjs +319 -0
  27. package/src/doctor/local.mjs +2 -1
  28. package/src/engines/sentinel.mjs +27 -6
  29. package/src/gates/detect.mjs +21 -8
  30. package/src/gates/expand.mjs +202 -0
  31. package/src/gates/owned-count.mjs +66 -0
  32. package/src/gates/run.mjs +49 -12
  33. package/src/install/wizard/answers.mjs +34 -5
  34. package/src/install/wizard/flags.mjs +8 -1
  35. package/src/install/wizard/project-file.mjs +2 -0
  36. package/src/install/wizard/run.mjs +6 -0
  37. package/src/install/wizard/steps.mjs +24 -4
  38. package/src/install/wizard/summary.mjs +13 -5
  39. package/src/ledger/export.mjs +1 -1
  40. package/src/ledger/outside-review.mjs +164 -0
  41. package/src/ledger/report.mjs +17 -5
  42. package/src/proof/export.mjs +93 -10
  43. package/src/proof/lock.mjs +28 -5
  44. package/src/proof/worktree.mjs +482 -0
  45. package/src/review/gate-check.mjs +9 -5
  46. package/src/review/open-findings.mjs +59 -0
  47. package/src/state/block-end.mjs +290 -0
  48. package/src/state/block.mjs +103 -39
  49. package/src/state/brief-status.mjs +131 -0
  50. package/src/state/config-snapshot.mjs +51 -0
  51. package/src/state/registry.mjs +35 -4
  52. package/src/state/run.mjs +10 -1
  53. package/src/util/breadcrumbs.mjs +1 -0
  54. package/src/util/forbidden.mjs +36 -1
  55. package/src/util/gate-token.mjs +8 -0
  56. package/src/worker/engine.mjs +157 -17
  57. package/src/worker/loop.mjs +26 -7
  58. package/src/worker/queue.mjs +36 -10
  59. package/src/worker/ticket.mjs +46 -6
@@ -0,0 +1,290 @@
1
+ /**
2
+ * B64b (issue #13): the block-end signal — a done file and the owner's `hooks.on_block_end` hook.
3
+ *
4
+ * A block ends where `src/state/block.mjs` sets its status to `closed` or `stopped`: `closeBlock`
5
+ * closing it, `closeBlock` stopping it (a pinned worker event or `ledger.tamper`), and `stopBlock`
6
+ * (`block stop`). After each end — its ledger rows written and the run lock released — the CLI
7
+ * (`src/cli/block.mjs`):
8
+ *
9
+ * 1. writes `<runs>/<run>/blocks/<id>.done.json` ({@link doneFilePath}) atomically (a temp file in
10
+ * the same directory, then a rename; a second end overwrites it) with {@link doneFileContent}:
11
+ * the block, the run, its status, the end's event and reason, the HEAD commit of the block's
12
+ * tree, the block's own spend and its open findings, and the time. Never transcript text, diff
13
+ * text or secrets. A failed write changes nothing but one stderr warning line.
14
+ * 2. runs `hooks.on_block_end` (`blockEndHook` in `config-snapshot.mjs`, from the run-start
15
+ * config snapshot only — the key is immutable) once, outside the run lock: argv only (no
16
+ * shell), cwd the repository top, stdin closed, stdout and stderr discarded, the parent env plus
17
+ * `CODE_FORGE_RUN`, `CODE_FORGE_BLOCK`, `CODE_FORGE_BLOCK_STATUS` and `CODE_FORGE_DONE_FILE`;
18
+ * its process group is killed at `timeout_s`. One signed `hook.ran` row records it (under the run lock). A hook
19
+ * that fails, is missing, times out or crashes changes nothing but one stderr warning line.
20
+ */
21
+
22
+ import { spawn } from 'node:child_process';
23
+ import { mkdir, rename, rm, stat, writeFile } from 'node:fs/promises';
24
+ import path from 'node:path';
25
+ import { runSpend } from '../ledger/spend.mjs';
26
+ import { readAllRows } from '../ledger/write.mjs';
27
+ import { exec } from '../util/exec.mjs';
28
+ import { assertRunId, runsDir } from './paths.mjs';
29
+ import { withRunLock, writeSigned } from './run.mjs';
30
+
31
+ // B64b: the hook's config reader lives next to `externalReviewer` (read by `state/block.mjs` too)
32
+ export { HOOK_TIMEOUT_DEFAULT_S, HOOK_TIMEOUT_MAX_S, HOOK_TIMEOUT_MIN_S, blockEndHook } from './config-snapshot.mjs';
33
+
34
+ /**
35
+ * The block's done file: `<runs>/<run>/blocks/<id>.done.json` (the run's own directory under the
36
+ * runs dir, next to the run record — never in the source tree).
37
+ * @param {string} runId @param {string} id
38
+ * @returns {string} an absolute path
39
+ */
40
+ export function doneFilePath(runId, id) {
41
+ return path.resolve(runsDir(), assertRunId(runId), 'blocks', `${safeId(id)}.done.json`);
42
+ }
43
+
44
+ /** @param {string} id @returns {string} the id, refused when it could leave the blocks dir. */
45
+ function safeId(id) {
46
+ if (typeof id !== 'string' || id.length === 0 || id.includes('/') || id.includes('\\') || id === '.' || id === '..' || id.includes('\0')) {
47
+ throw new TypeError(`block id ${JSON.stringify(id)} cannot name a done file`);
48
+ }
49
+ return id;
50
+ }
51
+
52
+ /**
53
+ * The done file's content, keys in this order. Pure.
54
+ * @param {{
55
+ * block: string, runId: string, status: 'closed' | 'stopped', event: string, reason?: string | null,
56
+ * commit: string | null, spend: {usd: number, unknown: number} | null, findings: number | null, at: Date,
57
+ * }} facts
58
+ * @returns {{block: string, run_id: string, status: string, event: string, reason: string | null, commit: string | null,
59
+ * spent_usd: number | null, unknown_usd_sessions: number | null, findings: number | null, at: string}}
60
+ */
61
+ export function doneFileContent({ block, runId, status, event, reason = null, commit, spend, findings, at }) {
62
+ return {
63
+ block,
64
+ run_id: runId,
65
+ status,
66
+ event,
67
+ reason: typeof reason === 'string' ? reason : null,
68
+ commit: typeof commit === 'string' && commit.length > 0 ? commit : null,
69
+ // a non-finite spend or a non-integer count is unknown (null) — the keys are never dropped
70
+ spent_usd: spend && Number.isFinite(spend.usd) ? spend.usd : null,
71
+ unknown_usd_sessions: spend && Number.isInteger(spend.unknown) ? spend.unknown : null,
72
+ findings: Number.isInteger(findings) ? findings : null,
73
+ at: at.toISOString(),
74
+ };
75
+ }
76
+
77
+ /** @param {string} cwd @returns {Promise<string | null>} HEAD of `cwd`'s repository, or null. */
78
+ async function headCommit(cwd) {
79
+ try {
80
+ const env = {};
81
+ for (const [name, value] of Object.entries(process.env)) if (!name.startsWith('GIT_')) env[name] = value;
82
+ const res = await exec(['git', 'rev-parse', '--verify', 'HEAD'], {
83
+ cwd,
84
+ env: { ...env, GIT_CONFIG_NOSYSTEM: '1', GIT_CONFIG_GLOBAL: process.platform === 'win32' ? 'NUL' : '/dev/null', GIT_TERMINAL_PROMPT: '0' },
85
+ timeoutMs: 10000,
86
+ });
87
+ const sha = res.stdout.trim();
88
+ return res.result === 'ok' && /^[0-9a-f]{40,64}$/.test(sha) ? sha : null;
89
+ } catch {
90
+ return null;
91
+ }
92
+ }
93
+
94
+ /**
95
+ * Write the block's done file after an end (its ledger rows are already written). Never throws: a
96
+ * failure prints `warning: done file not written: <reason>` on stderr and returns null.
97
+ * @param {{
98
+ * record: Record<string, any>, id: string, status: 'closed' | 'stopped', event: string, reason?: string | null,
99
+ * openFindings: (rows: Record<string, any>[]) => Promise<number>, err: (s: string) => unknown, now?: Date,
100
+ * }} opts - `record` is the run record read before the end (its `workspace` and the block's
101
+ * `worktree` do not change at an end); `openFindings` counts the block's open findings exactly
102
+ * as `code-forge status` does.
103
+ * @returns {Promise<string | null>} the done file's absolute path, or null when it was not written.
104
+ */
105
+ export async function writeDoneFile({ record, id, status, event, reason = null, openFindings, err, now = new Date() }) {
106
+ const runId = String(record?.run_id);
107
+ try {
108
+ const file = doneFilePath(runId, id);
109
+ /** @type {Record<string, any>[] | null} */
110
+ let rows = null;
111
+ try {
112
+ rows = await readAllRows(record.project);
113
+ } catch {
114
+ rows = null;
115
+ }
116
+ const spend = rows === null ? null : runSpend(rows.filter((r) => r?.block === id), runId);
117
+ // like the spend: unknown (null) when the rows cannot be read or the count fails — never a 0
118
+ /** @type {number | null} */
119
+ let findings = null;
120
+ if (rows !== null) {
121
+ try {
122
+ const n = await openFindings(rows);
123
+ findings = Number.isInteger(n) && n >= 0 ? n : null;
124
+ } catch {
125
+ findings = null;
126
+ }
127
+ }
128
+ const tree = record.blocks?.[id]?.worktree?.path;
129
+ const commit = await headCommit(typeof tree === 'string' && tree.length > 0 ? tree : record.workspace);
130
+ const content = doneFileContent({ block: id, runId, status, event, reason, commit, spend, findings, at: now });
131
+ const dir = path.dirname(file);
132
+ await mkdir(dir, { recursive: true, mode: 0o700 });
133
+ const tmp = path.join(dir, `.${path.basename(file)}.${process.pid}.${Date.now()}.tmp`);
134
+ try {
135
+ await writeFile(tmp, `${JSON.stringify(content)}\n`, { mode: 0o600 });
136
+ await rename(tmp, file);
137
+ } catch (thrown) {
138
+ await rm(tmp, { force: true }).catch(() => {});
139
+ throw thrown;
140
+ }
141
+ return file;
142
+ } catch (thrown) {
143
+ err(`warning: done file not written: ${reasonOf(thrown)}\n`);
144
+ return null;
145
+ }
146
+ }
147
+
148
+ /** @param {unknown} thrown @returns {string} a one-line reason (an error code or message). */
149
+ function reasonOf(thrown) {
150
+ const any = /** @type {any} */ (thrown);
151
+ const text = typeof any?.code === 'string' ? any.code : typeof any?.message === 'string' ? any.message : String(thrown);
152
+ return text.split('\n')[0];
153
+ }
154
+
155
+ /**
156
+ * @typedef {{exit: number | null, signal: string | null, timedOut: boolean, notFound: boolean, error: string | null}} SpawnResult
157
+ */
158
+
159
+ /** @param {unknown} thrown @returns {string} the error's code (`EACCES`, …), else `UNKNOWN` — never its message. */
160
+ function errorCode(thrown) {
161
+ const code = /** @type {any} */ (thrown)?.code;
162
+ return typeof code === 'string' && /^[A-Z0-9_]{1,32}$/.test(code) ? code : 'UNKNOWN';
163
+ }
164
+
165
+ /**
166
+ * The warning's tail for a hook that did not exit 0 (ruling round 2 (b)), or null for a clean exit:
167
+ * `exit N`, `timed out`, `not found`, `signal <NAME>` or `failed to start (<code>)`.
168
+ * @param {SpawnResult} ran @returns {string | null}
169
+ */
170
+ export function hookWarning(ran) {
171
+ if (ran.timedOut) return 'timed out';
172
+ if (ran.notFound) return 'not found';
173
+ if (ran.error !== null) return `failed to start (${ran.error})`;
174
+ if (typeof ran.exit === 'number') return ran.exit === 0 ? null : `exit ${ran.exit}`;
175
+ if (typeof ran.signal === 'string') return `signal ${ran.signal}`;
176
+ return 'failed to start (UNKNOWN)';
177
+ }
178
+
179
+ /**
180
+ * Spawn the hook: no shell, stdin closed, stdout and stderr discarded, its own process group,
181
+ * killed (the whole group) at the timeout.
182
+ * @param {string[]} argv @param {{cwd: string, env: NodeJS.ProcessEnv, timeoutMs: number}} opts
183
+ * `notFound` is an ENOENT for `argv[0]` itself; every other spawn error (a missing cwd included —
184
+ * Node names `argv[0]` in that ENOENT too, so the cwd is checked first) is `error`, its code.
185
+ * @returns {Promise<SpawnResult>}
186
+ */
187
+ async function spawnHook(argv, { cwd, env, timeoutMs }) {
188
+ try {
189
+ if (!(await stat(cwd)).isDirectory()) return { exit: null, signal: null, timedOut: false, notFound: false, error: 'ENOTDIR' };
190
+ } catch (thrown) {
191
+ return { exit: null, signal: null, timedOut: false, notFound: false, error: errorCode(thrown) };
192
+ }
193
+ return new Promise((resolve) => {
194
+ let timedOut = false;
195
+ let settled = false;
196
+ /** @type {NodeJS.Timeout | undefined} */
197
+ let timer;
198
+ const done = (/** @type {SpawnResult} */ r) => {
199
+ if (settled) return;
200
+ settled = true;
201
+ clearTimeout(timer);
202
+ resolve(r);
203
+ };
204
+ const failed = (/** @type {unknown} */ thrown) => {
205
+ const any = /** @type {any} */ (thrown);
206
+ const notFound = any?.code === 'ENOENT' && (any?.path === undefined || any.path === argv[0]);
207
+ done({ exit: null, signal: null, timedOut: false, notFound, error: notFound ? null : errorCode(thrown) });
208
+ };
209
+ let child;
210
+ try {
211
+ child = spawn(argv[0], argv.slice(1), { cwd, env, shell: false, detached: true, stdio: ['ignore', 'ignore', 'ignore'] });
212
+ } catch (thrown) {
213
+ failed(thrown);
214
+ return;
215
+ }
216
+ const pid = child.pid;
217
+ child.on('error', failed);
218
+ child.on('close', (code, signal) => done({ exit: code, signal: signal ?? null, timedOut, notFound: false, error: null }));
219
+ timer = setTimeout(() => {
220
+ timedOut = true;
221
+ try {
222
+ if (typeof pid === 'number' && pid > 1) process.kill(-pid, 'SIGKILL');
223
+ else child.kill('SIGKILL');
224
+ } catch {
225
+ try {
226
+ child.kill('SIGKILL');
227
+ } catch {
228
+ // already gone
229
+ }
230
+ }
231
+ }, timeoutMs);
232
+ });
233
+ }
234
+
235
+ /**
236
+ * Run `hooks.on_block_end` once for an end, outside the run lock, then write one signed `hook.ran`
237
+ * row under it (also when the hook failed, timed out or was not found). Never throws, never prints
238
+ * on stdout; a hook that did not exit 0 prints one `warning: on_block_end hook <tail>` line on
239
+ * stderr ({@link hookWarning}). `doneFile` is the empty string when the done file was not written.
240
+ * @param {{
241
+ * hook: {argv: string[], timeoutS: number}, runId: string, id: string, status: string, doneFile: string,
242
+ * repoTop: string, writeRow: (row: Record<string, any>) => Promise<unknown>, err: (s: string) => unknown,
243
+ * }} opts
244
+ * @returns {Promise<Record<string, any> | null>} the row written (unsigned fields), or null when none was.
245
+ */
246
+ export async function runBlockEndHook({ hook, runId, id, status, doneFile, repoTop, writeRow, err }) {
247
+ const env = {
248
+ ...process.env,
249
+ CODE_FORGE_RUN: runId,
250
+ CODE_FORGE_BLOCK: id,
251
+ CODE_FORGE_BLOCK_STATUS: status,
252
+ CODE_FORGE_DONE_FILE: doneFile,
253
+ };
254
+ const started = process.hrtime.bigint();
255
+ /** @type {SpawnResult} */
256
+ let ran;
257
+ // "failed to start" covers the spawn only; a later failure gets neutral wording below
258
+ try {
259
+ ran = await spawnHook(hook.argv, { cwd: repoTop, env, timeoutMs: hook.timeoutS * 1000 });
260
+ } catch (thrown) {
261
+ ran = { exit: null, signal: null, timedOut: false, notFound: false, error: errorCode(thrown) };
262
+ }
263
+ try {
264
+ const durationMs = Math.round(Number(process.hrtime.bigint() - started) / 1e6);
265
+ const warning = hookWarning(ran);
266
+ if (warning !== null) err(`warning: on_block_end hook ${warning}\n`);
267
+ const row = {
268
+ event: 'hook.ran',
269
+ block: id,
270
+ hook: 'on_block_end',
271
+ argv0: path.basename(hook.argv[0]),
272
+ exit: ran.exit,
273
+ signal: ran.signal,
274
+ timed_out: ran.timedOut,
275
+ duration_ms: durationMs,
276
+ };
277
+ try {
278
+ // clause 2: the hook.ran row is appended UNDER the run lock (writeSigned alone takes no lock)
279
+ await withRunLock(runId, () => writeSigned(runId, writeRow, row));
280
+ } catch (thrown) {
281
+ // an error code only, never raw message text
282
+ err(`warning: on_block_end hook row not written (${errorCode(thrown)})\n`);
283
+ return null;
284
+ }
285
+ return row;
286
+ } catch (thrown) {
287
+ err(`warning: on_block_end hook not recorded (${errorCode(thrown)})\n`);
288
+ return null;
289
+ }
290
+ }
@@ -18,11 +18,11 @@ import path from 'node:path';
18
18
  import { BLOCK_KINDS, blockKind, dispatchLevel } from '../decide/escalation.mjs';
19
19
  import { exec } from '../util/exec.mjs';
20
20
  import { DEFAULT_CONFIG_FILENAME } from '../config/load.mjs';
21
- import { externalReviewer } from './config-snapshot.mjs';
21
+ import { blockEndHook, externalReviewer } from './config-snapshot.mjs';
22
22
  import { ownedKinds, pathKind } from './owned-kinds.mjs';
23
23
  import { isAncestorArgv, revParseArgv } from '../util/git.mjs';
24
24
  import { StateError } from './paths.mjs';
25
- import { assertOwned, findOverlap, isExactEntry, ownsFile, scopeGate } from './registry.mjs';
25
+ import { assertOwned, blocksOverlap, isExactEntry, ownsFile, scopeGate } from './registry.mjs';
26
26
  import { checkWorkerPin, readRun, saveRun, withRunLock, writeSigned } from './run.mjs';
27
27
  import { verifyRow, loadKey } from './signer.mjs';
28
28
 
@@ -261,34 +261,42 @@ function activeBlocks(record) {
261
261
  * is the owner's switch past the local per-file review, so no block may own it. These are the
262
262
  * spellings an owned entry may match it by: relative to the workspace and, for a workspace in a
263
263
  * subdirectory, relative to the repository top level (the close gate matches both, `ownsAt`).
264
- * Empty when the key is not set.
265
- * @param {Record<string, any>} record @returns {Promise<string[]>}
264
+ * Empty when the key is not set. B64b: `hooks.on_block_end` in the snapshot guards it the same way
265
+ * — the file names a command the orchestrator runs at every block end — and `why` names the key
266
+ * that guards it (`review.external.required` first, so its message is unchanged).
267
+ * @param {Record<string, any>} record @returns {Promise<{paths: string[], why: string}>}
266
268
  * @throws {StateError} `config-snapshot`, `git-failed`
267
269
  */
268
270
  async function guardedConfigPaths(record) {
269
- if (externalReviewer(record) === null) return [];
271
+ const why = externalReviewer(record) !== null ? 'review.external.required is true' : blockEndHook(record) !== null ? 'hooks.on_block_end is set' : null;
272
+ if (why === null) return { paths: [], why: '' };
270
273
  const top = await repoTop(record.workspace);
271
274
  // the repo-top spelling `<prefix>/.code-forge.yml` may also match a different file of the same
272
275
  // name under the workspace (`<ws>/<prefix>/.code-forge.yml`): that over-match only refuses more,
273
276
  // never less, and is the one `ownsAt` gives the close gate
274
277
  const prefix = path.relative(top, realpathSync(record.workspace)).split(path.sep).join('/');
275
- return prefix === '' ? [DEFAULT_CONFIG_FILENAME] : [DEFAULT_CONFIG_FILENAME, `${prefix}/${DEFAULT_CONFIG_FILENAME}`];
278
+ return { paths: prefix === '' ? [DEFAULT_CONFIG_FILENAME] : [DEFAULT_CONFIG_FILENAME, `${prefix}/${DEFAULT_CONFIG_FILENAME}`], why };
276
279
  }
277
280
 
278
281
  /**
279
282
  * B58: refuse an owned entry that owns one of `guarded` (B57's rule, `ownsFile`: the exact path,
280
283
  * a directory above it, or a glob that matches it).
281
- * @param {ReadonlyArray<string>} owned @param {import('./registry.mjs').OwnedKinds} kinds @param {ReadonlyArray<string>} guarded
284
+ * @param {ReadonlyArray<string>} owned @param {import('./registry.mjs').OwnedKinds} kinds @param {{paths: ReadonlyArray<string>, why: string}} guarded
282
285
  * @throws {StateError} `config-owned`
283
286
  */
284
287
  function refuseConfigOwner(owned, kinds, guarded) {
285
288
  for (const entry of owned) {
286
- if (guarded.some((file) => ownsFile([entry], file, kinds))) {
287
- throw new StateError('config-owned', `owned entry ${JSON.stringify(entry)} covers the project's ${DEFAULT_CONFIG_FILENAME}; no block may own it while review.external.required is true`);
289
+ if (guarded.paths.some((file) => ownsFile([entry], file, kinds))) {
290
+ throw new StateError('config-owned', `owned entry ${JSON.stringify(entry)} covers the project's ${DEFAULT_CONFIG_FILENAME}; no block may own it while ${guarded.why}`);
288
291
  }
289
292
  }
290
293
  }
291
294
 
295
+ /**
296
+ * @typedef {{path: string, branch: string, base_sha: string}} TreeRecord - a block entry's `worktree` (B62a).
297
+ * @typedef {{worktree: TreeRecord, created: boolean, rollback: (() => Promise<string | null>) | null}} OpenTree - `rollback` resolves to a sentence naming leftovers, or null.
298
+ */
299
+
292
300
  /**
293
301
  * `block open` — refuses a second open of the same id and any owned set that overlaps another
294
302
  * ACTIVE block's; records base, owned files, level, attempt, acceptance, line forecast and the
@@ -304,13 +312,29 @@ function refuseConfigOwner(owned, kinds, guarded) {
304
312
  * `dispatch` row — under the same lock, so a refused open writes neither. Its `answer` is the
305
313
  * requested lane and must equal `level` (else `bad-decision`, nothing written); the docs floor may
306
314
  * still raise the dispatched level, and the dispatch row then names the lane as `requested_level`.
315
+ * B60: `tests` (`block open --tests <paths…>`, the same path rules as owned entries, else
316
+ * `bad-tests`) is recorded as the entry's `test_files` and on the dispatch row; a gate argv item
317
+ * `{test_files}` expands to them (`gates/expand.mjs`). Left out, neither carries the key.
318
+ * B62a: `worktree` (given by `block open` under `proof.isolation: worktree`, `proof/worktree.mjs`;
319
+ * this module never imports it) plans the block's own worktree, or the reuse of the one an earlier
320
+ * entry of the same id recorded (`previous`); its `make()` makes or reuses it. Both are called
321
+ * under the lock before anything is recorded: the plan before the overlap check (which lets this
322
+ * block share owned paths with another open block on a different tree, `blocksOverlap`), `make()`
323
+ * after every other check. A refusal there writes nothing; if the rows or the save fail after
324
+ * `make()` created a tree, its `rollback()` removes it again, and the original error is thrown
325
+ * with a sentence added naming anything the rollback could not remove. The entry stores the tree's
326
+ * `{path, branch, base_sha}` as `worktree`; a tree it made is a signed `worktree.created
327
+ * {block, path, branch, base}` row just before `dispatch`. On a re-open the block's base is the
328
+ * tree's `base_sha`, and a `base` that resolves to another commit is refused (`worktree-base`).
307
329
  * @param {{
308
330
  * runId: string, id: string, level: string, owned: string[], acceptance: unknown,
309
331
  * attempt?: number, base?: string, lines?: number, brief?: {path: string, content: Buffer | string},
310
- * kind?: string, cfg?: Record<string, any>, writeRow: WriteRow, now?: Date,
332
+ * kind?: string, cfg?: Record<string, any>, writeRow: WriteRow, now?: Date, tests?: string[],
311
333
  * decision?: {question: 'lane', answer: string, source: 'rules', facts: Record<string, unknown>}, decisionId?: string,
334
+ * worktree?: (ctx: {baseSha: string, previous: TreeRecord | null}) => Promise<{path: string, make: () => Promise<OpenTree>}>,
312
335
  * }} opts
313
- * @returns {Promise<{block: Record<string, any>, pointer: string | null}>}
336
+ * @returns {Promise<{block: Record<string, any>, pointer: string | null, worktreeReused: boolean}>} `worktreeReused`:
337
+ * the block took an earlier attempt's worktree as it was.
314
338
  */
315
339
  export async function openBlock(opts) {
316
340
  const { runId, id, level, attempt = 1, lines = null, writeRow, now = new Date() } = opts;
@@ -322,6 +346,7 @@ export async function openBlock(opts) {
322
346
  if (opts.decision && opts.decision.answer !== level) throw new StateError('bad-decision', `the decision answer ${opts.decision.answer} is not the requested level ${level}`);
323
347
  const owned = assertOwned(opts.owned);
324
348
  const acceptance = assertAcceptance(opts.acceptance);
349
+ const tests = opts.tests === undefined ? null : [...assertOwned(opts.tests, { label: 'test files', code: 'bad-tests' })];
325
350
  const kind = blockKind({ owned, declared: opts.kind });
326
351
  const dispatched = dispatchLevel({ lane: level, kind, cfg: opts.cfg });
327
352
  const pointer = opts.brief ? briefPointer(opts.brief.path, opts.brief.content) : null;
@@ -330,35 +355,70 @@ export async function openBlock(opts) {
330
355
  // not wait on it); under the lock the base is resolved again and must be the one stamped against
331
356
  const before = await readRun(runId);
332
357
  const { workspace } = before;
333
- const stampedBase = await resolveCommit(opts.base ?? 'HEAD', workspace);
358
+ // B62a: a re-open of a block with a worktree keeps the tree's base — the block's base is the
359
+ // commit its tree started from, and a different --base is refused before anything is touched
360
+ /** @param {Record<string, any>} record @returns {{path: string, branch: string, base_sha: string} | null} */
361
+ const previousTree = (record) => (opts.worktree ? record.blocks[id]?.worktree ?? null : null);
362
+ const previous = previousTree(before);
363
+ const baseRef = previous ? previous.base_sha : opts.base ?? 'HEAD';
364
+ const stampedBase = await resolveCommit(baseRef, workspace);
365
+ if (previous && opts.base !== undefined && (await resolveCommit(opts.base, workspace)) !== previous.base_sha) {
366
+ throw new StateError('worktree-base', `block ${id}: --base ${opts.base} is not ${previous.base_sha.slice(0, 12)}, the base of the block's worktree ${previous.path}; a re-open keeps the worktree and its base — drop --base; nothing was recorded`);
367
+ }
334
368
  const treeOpen = await treeAtOpen(workspace, stampedBase);
335
369
  // B58: `review.external` cannot change mid-run, so the record read here decides
336
370
  const guarded = await guardedConfigPaths(before);
337
371
 
338
- const block = await mutateRun(runId, null, writeRow, async (record) => {
339
- if (record.blocks[id]?.status === 'open') throw new StateError('already-open', `block ${id} is already open`);
340
- // B57: what each exact owned entry is on disk, under the lock (as `block claim`) — a regular
341
- // file owns only itself, here and in every later check
342
- const kinds = ownedKinds(record.workspace, owned);
343
- refuseConfigOwner(owned, kinds, guarded);
344
- for (const [otherId, other] of Object.entries(activeBlocks(record))) {
345
- const pair = findOverlap(owned, other.owned_files, { kindsA: kinds, kindsB: other.owned_kinds });
346
- if (pair) throw new StateError('overlap', `block ${id} owns ${pair[0]}, which overlaps ${pair[1]} owned by open block ${otherId}`);
372
+ /** @type {OpenTree | null} */
373
+ let made = null;
374
+ try {
375
+ const { block, reused } = await mutateRun(runId, null, writeRow, async (record) => {
376
+ if (record.blocks[id]?.status === 'open') throw new StateError('already-open', `block ${id} is already open`);
377
+ // B57: what each exact owned entry is on disk, under the lock (as `block claim`) — a regular
378
+ // file owns only itself, here and in every later check
379
+ const kinds = ownedKinds(record.workspace, owned);
380
+ refuseConfigOwner(owned, kinds, guarded);
381
+ const prior = previousTree(record);
382
+ const baseSha = await resolveCommit(prior ? prior.base_sha : opts.base ?? 'HEAD', record.workspace);
383
+ if (baseSha !== stampedBase || record.workspace !== workspace) throw new StateError('base-moved', `the base moved while block ${id} was being opened; run block open again`);
384
+ // B62a: the tree is planned (read-only checks) before the overlap check, which needs its path
385
+ const planned = opts.worktree ? await opts.worktree({ baseSha, previous: prior }) : null;
386
+ const self = { owned_files: owned, owned_kinds: kinds, worktree: planned ? { path: planned.path } : null };
387
+ for (const [otherId, other] of Object.entries(activeBlocks(record))) {
388
+ const pair = blocksOverlap(self, other);
389
+ if (pair) throw new StateError('overlap', `block ${id} owns ${pair[0]}, which overlaps ${pair[1]} owned by open block ${otherId}`);
390
+ }
391
+ // B62a: the worktree is made (or an earlier attempt's reused) last, once every other check
392
+ // passed; a tree made here is rolled back below if the rows or the save then fail
393
+ made = planned ? await planned.make() : null;
394
+ const tree = made;
395
+ const entry = { block: id, base_sha: baseSha, owned_files: [...owned], owned_kinds: { ...kinds }, level: dispatched.level, kind, attempt, opened_at: now.toISOString(), acceptance, lines_forecast: lines, status: 'open', tree_at_open: treeOpen, ...(tests ? { test_files: tests } : {}), ...(tree ? { worktree: tree.worktree } : {}) };
396
+ record.blocks[id] = entry;
397
+ const floor = dispatched.trigger === 'docs_floor' ? { requested_level: level, trigger: 'docs_floor' } : {};
398
+ const created = tree?.created ? [{ event: 'worktree.created', block: id, path: tree.worktree.path, branch: tree.worktree.branch, base: tree.worktree.base_sha }] : [];
399
+ return {
400
+ result: { block: entry, reused: tree !== null && !tree.created },
401
+ rows: [
402
+ ...(opts.decision ? [{ ...opts.decision, event: 'decision', decision_id: opts.decisionId ?? randomUUID(), block: id }] : []),
403
+ ...created,
404
+ { event: 'dispatch', block: id, level: dispatched.level, kind, ...floor, attempt, base_sha: baseSha, owned_files: entry.owned_files, lines_forecast: lines, ...(tests ? { test_files: tests } : {}) },
405
+ ],
406
+ };
407
+ });
408
+ return { block, pointer, worktreeReused: reused };
409
+ } catch (err) {
410
+ // B62a: nothing was recorded, so a tree this open made must not outlive it
411
+ if (made?.created && made.rollback) {
412
+ // the original error is thrown either way; what could not be removed is named in it (the
413
+ // rollback checks each git result and resolves, it does not throw)
414
+ const left = await made.rollback();
415
+ if (left !== null) {
416
+ if (!(err instanceof Error)) throw new Error(`${String(err).replace(/\.?$/, '.')} ${left}`, { cause: err });
417
+ err.message = `${err.message.replace(/\.?$/, '.')} ${left}`;
418
+ }
347
419
  }
348
- const baseSha = await resolveCommit(opts.base ?? 'HEAD', record.workspace);
349
- if (baseSha !== stampedBase || record.workspace !== workspace) throw new StateError('base-moved', `the base moved while block ${id} was being opened; run block open again`);
350
- const entry = { block: id, base_sha: baseSha, owned_files: [...owned], owned_kinds: { ...kinds }, level: dispatched.level, kind, attempt, opened_at: now.toISOString(), acceptance, lines_forecast: lines, status: 'open', tree_at_open: treeOpen };
351
- record.blocks[id] = entry;
352
- const floor = dispatched.trigger === 'docs_floor' ? { requested_level: level, trigger: 'docs_floor' } : {};
353
- return {
354
- result: entry,
355
- rows: [
356
- ...(opts.decision ? [{ ...opts.decision, event: 'decision', decision_id: opts.decisionId ?? randomUUID(), block: id }] : []),
357
- { event: 'dispatch', block: id, level: dispatched.level, kind, ...floor, attempt, base_sha: baseSha, owned_files: entry.owned_files, lines_forecast: lines },
358
- ],
359
- };
360
- });
361
- return { block, pointer };
420
+ throw err;
421
+ }
362
422
  }
363
423
 
364
424
  /** @param {{runId: string, id: string, writeRow: WriteRow}} opts */
@@ -374,12 +434,15 @@ export async function attemptBlock({ runId, id, writeRow }) {
374
434
  * in between touched a file this block owns (that is a conflict ⇒ stop). Rename detection is OFF
375
435
  * (`--no-renames`): a rename lists both its old and its new path, so moving an owned file away
376
436
  * is a conflict too. Never silent. Owned means `ownsFile` (B57: a directory entry owns the files
377
- * below it).
437
+ * below it). B62a: refused for a block with its own worktree (`worktree-rebase`).
378
438
  * @param {{runId: string, id: string, head?: string, writeRow: WriteRow}} opts
379
439
  * @returns {Promise<{old_base: string, new_base: string}>}
380
440
  */
381
441
  export async function rebaseBlock({ runId, id, head = 'HEAD', writeRow }) {
382
442
  return mutateRun(runId, id, writeRow, async (record, block) => {
443
+ // B62a: a worktree block's base is the commit its tree started from; moving it would leave the
444
+ // tree on its old base, so the rebase is refused
445
+ if (block.worktree) throw new StateError('worktree-rebase', `block ${id} has its own worktree ${block.worktree.path} on branch ${block.worktree.branch}; its base is the base of that tree, so block rebase does not apply — work on the branch in the tree instead`);
383
446
  const cwd = record.workspace;
384
447
  const newBase = await resolveCommit(head, cwd);
385
448
  const ancestor = await git(isAncestorArgv(block.base_sha, newBase), cwd, [0, 1]);
@@ -396,10 +459,11 @@ export async function rebaseBlock({ runId, id, head = 'HEAD', writeRow }) {
396
459
  /**
397
460
  * `block claim` — assign one EXACT path (an orphan, or any unowned path) to `id`; refused for a
398
461
  * glob, and when another open block owns it — or anything it would own: the claimed path becomes an
399
- * owned entry, so it is checked with `findOverlap` (B57: an entry owns the paths below it too,
462
+ * owned entry, so it is checked with `blocksOverlap` (B57: an entry owns the paths below it too,
400
463
  * unless it is a regular file now — its kind is recorded in `owned_kinds` as at `block open`).
401
464
  * B58: with `review.external.required` set, a path that owns the project's config is refused
402
- * (`config-owned`), as at `block open`.
465
+ * (`config-owned`), as at `block open`. B62a: two blocks that each have their own worktree may
466
+ * own the same path (`blocksOverlap`).
403
467
  * @param {{runId: string, id: string, file: string, writeRow: WriteRow}} opts
404
468
  */
405
469
  export async function claimPath({ runId, id, file, writeRow }) {
@@ -410,7 +474,7 @@ export async function claimPath({ runId, id, file, writeRow }) {
410
474
  const kinds = { [file]: pathKind(record.workspace, file) };
411
475
  refuseConfigOwner([file], kinds, guarded);
412
476
  for (const [otherId, other] of Object.entries(activeBlocks(record))) {
413
- const pair = otherId === id ? null : findOverlap([file], other.owned_files, { kindsA: kinds, kindsB: other.owned_kinds });
477
+ const pair = otherId === id ? null : blocksOverlap({ owned_files: [file], owned_kinds: kinds, worktree: block.worktree ?? null }, other);
414
478
  if (pair) throw new StateError('overlap', `${file} overlaps ${pair[1]} owned by open block ${otherId}`);
415
479
  }
416
480
  if (!block.owned_files.includes(file)) {
@@ -0,0 +1,131 @@
1
+ /**
2
+ * B64a (issue #13 item 6): the short status of one run — the data `code-forge status` prints, as
3
+ * text or as one JSON line. A pure function: it reads the run record and the ledger rows it is
4
+ * handed and nothing else (no file, no lock, no row written); the CLI (`src/cli/status.mjs`) reads
5
+ * them and renders the result. The object it returns has exactly the keys of the `--json` run
6
+ * object, in order: run_id, status, mode, spent_usd, unknown_usd_sessions, budget_usd, blocks,
7
+ * more, closed, stopped.
8
+ *
9
+ * Per run: its status (null when the record has none — `run status` then prints no status either;
10
+ * the text shows `-`), its mode (`brief` for a `run start --mode brief` run, else `plan`), the
11
+ * spend (`runSpend`, the same numbers `run status` prints as spent_usd and unknown_usd_sessions;
12
+ * both null when the caller could not read the ledger, `rows: null`) and the budget the caller
13
+ * read, then one entry per block that is neither closed nor stopped — closed and stopped blocks are
14
+ * only counted. A block entry holds:
15
+ * - `state`: `open` on its first attempt, `attempt-<n>` after `block attempt` raised it to n; a
16
+ * block whose record status is anything other than open shows that status as its state;
17
+ * - `level`: the level it was dispatched at (the run record);
18
+ * - `model`: `<provider>/<model>` (or the model alone) of the block's latest coder `session` /
19
+ * `session.background` row of the run that names a model — the rows `src/session/spawn.mjs`
20
+ * writes carry `role`, `provider`, `model`; a `ledger add coder` row (`manual`, no model) is
21
+ * skipped — null when the ledger names none;
22
+ * - `gate`: the block's LAST gate run — `gate.done` says pass (`all_ok: true`) or fail, a
23
+ * `gate.red` with no `gate.done` after it is a fail; the name is the failing gate (the last
24
+ * `gate.red` of that run), null on a pass; `{result: 'none', name: null}` when no gate ran;
25
+ * - `findings`: the open review findings of the block, counted by the caller's `findingsOf`
26
+ * (the CLI counts what the block close gate would refuse, `src/review/gate-check.mjs`).
27
+ * Blocks are in block id order (numeric-aware: B2 before B10).
28
+ */
29
+
30
+ import { runSpend } from '../ledger/spend.mjs';
31
+
32
+ /**
33
+ * @typedef {{id: string, state: string, level: string | null, model: string | null, gate: {result: 'pass' | 'fail' | 'none', name: string | null}, findings: number}} BlockStatus
34
+ * @typedef {{run_id: string, status: string | null, mode: 'plan' | 'brief', spent_usd: number | null, unknown_usd_sessions: number | null, budget_usd: number | null, blocks: BlockStatus[], more: number, closed: number, stopped: number}} RunStatus
35
+ */
36
+
37
+ /** @param {string} a @param {string} b @returns {number} */
38
+ const byBlockId = (a, b) => a.localeCompare(b, 'en', { numeric: true }) || (a < b ? -1 : a > b ? 1 : 0);
39
+
40
+ /**
41
+ * The block's last gate run (see the module doc).
42
+ * @param {Array<Record<string, any>>} mine - the block's rows of the run, in ledger order.
43
+ * @returns {{result: 'pass' | 'fail' | 'none', name: string | null}}
44
+ */
45
+ export function lastGate(mine) {
46
+ /** @type {string | null} */
47
+ let red = null;
48
+ /** @type {{result: 'pass' | 'fail' | 'none', name: string | null}} */
49
+ let last = { result: 'none', name: null };
50
+ for (const r of mine) {
51
+ if (r.event === 'gate.red') {
52
+ red = typeof r.gate === 'string' ? r.gate : null;
53
+ last = { result: 'fail', name: red };
54
+ } else if (r.event === 'gate.done') {
55
+ last = r.all_ok === true ? { result: 'pass', name: null } : { result: 'fail', name: red };
56
+ red = null;
57
+ }
58
+ }
59
+ return last;
60
+ }
61
+
62
+ /**
63
+ * The engine/model of the block's latest coder session row, or null.
64
+ * @param {Array<Record<string, any>>} mine @returns {string | null}
65
+ */
66
+ export function coderModel(mine) {
67
+ for (let i = mine.length - 1; i >= 0; i -= 1) {
68
+ const r = mine[i];
69
+ if ((r.event === 'session' || r.event === 'session.background') && r.role === 'coder' && typeof r.model === 'string' && r.model.length > 0) {
70
+ return typeof r.provider === 'string' && r.provider.length > 0 ? `${r.provider}/${r.model}` : r.model;
71
+ }
72
+ }
73
+ return null;
74
+ }
75
+
76
+ /**
77
+ * @param {{
78
+ * record: Record<string, any>, rows: Array<Record<string, any>> | null, budget: number | null,
79
+ * findingsOf: (blockId: string) => number,
80
+ * }} opts - `rows`: the project's ledger rows (other runs are filtered out here), null when the
81
+ * ledger could not be read; `budget`: the workspace's `budget.usd` (null when unset), as
82
+ * `run status` reads it.
83
+ * @returns {RunStatus}
84
+ */
85
+ export function briefStatus({ record, rows, budget, findingsOf }) {
86
+ const runId = String(record.run_id);
87
+ /** @type {Map<string, Array<Record<string, any>>>} the run's rows, grouped by block once */
88
+ const byBlock = new Map();
89
+ for (const r of rows ?? []) {
90
+ if (r?.run !== runId || typeof r.block !== 'string') continue;
91
+ const list = byBlock.get(r.block);
92
+ if (list) list.push(r);
93
+ else byBlock.set(r.block, [r]);
94
+ }
95
+ const spend = rows == null ? null : runSpend(rows, runId);
96
+ const entries = record.blocks && typeof record.blocks === 'object' ? record.blocks : {};
97
+ const ids = Object.keys(entries).sort(byBlockId);
98
+ /** @type {BlockStatus[]} */
99
+ const blocks = [];
100
+ let closed = 0;
101
+ let stopped = 0;
102
+ for (const id of ids) {
103
+ const entry = entries[id];
104
+ const status = typeof entry?.status === 'string' ? entry.status : 'open';
105
+ if (status === 'closed') closed += 1;
106
+ else if (status === 'stopped') stopped += 1;
107
+ if (status === 'closed' || status === 'stopped') continue;
108
+ const mine = byBlock.get(id) ?? [];
109
+ const attempt = Number.isInteger(entry?.attempt) ? entry.attempt : 1;
110
+ blocks.push({
111
+ id,
112
+ state: status !== 'open' ? status : attempt > 1 ? `attempt-${attempt}` : 'open',
113
+ level: typeof entry?.level === 'string' ? entry.level : null,
114
+ model: coderModel(mine),
115
+ gate: lastGate(mine),
116
+ findings: findingsOf(id),
117
+ });
118
+ }
119
+ return {
120
+ run_id: runId,
121
+ status: typeof record.status === 'string' ? record.status : null,
122
+ mode: record.mode === 'brief' ? 'brief' : 'plan',
123
+ spent_usd: spend === null ? null : spend.usd,
124
+ unknown_usd_sessions: spend === null ? null : spend.unknown,
125
+ budget_usd: budget,
126
+ blocks,
127
+ more: 0,
128
+ closed,
129
+ stopped,
130
+ };
131
+ }