@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
@@ -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
+ }
@@ -34,6 +34,16 @@ export const IMMUTABLE_KEYS = Object.freeze({
34
34
  // B58: the owner's switch past the local per-file review is set before the run starts or not at
35
35
  // all, so nobody turns it on (or off) mid-run
36
36
  'review.external': 'the outside reviewer is fixed by the owner before the run starts',
37
+ // B62a: every block of the run is isolated the same way — only two blocks that each have their
38
+ // own worktree may share owned paths — so neither key changes mid-run
39
+ 'proof.isolation': 'every block of the run is isolated the same way',
40
+ 'proof.worktree': 'the worktrees and branches of the open blocks were made with it',
41
+ // B63b: it tightens `block close` (the coder's green test run), so it is authority like
42
+ // `review.external`; the rest of `context` stays live
43
+ 'context.require_coder_tests': 'whether close needs the coder\'s green test run is fixed by the owner before the run starts',
44
+ // B64b: the block-end hook runs a command on the owner's machine at every block end, so only
45
+ // the owner sets it, before the run starts
46
+ 'hooks.on_block_end': 'it runs a command on the owner\'s machine at every block end; fixed by the owner before the run starts',
37
47
  });
38
48
 
39
49
  /** @param {unknown} v @returns {boolean} what `JSON.stringify` drops from an object. */
@@ -184,3 +194,44 @@ export function externalReviewer(record) {
184
194
  const name = typeof external.name === 'string' ? external.name.trim() : '';
185
195
  return name === '' ? null : name; // trimmed: no row ever carries padding
186
196
  }
197
+
198
+ /**
199
+ * B63b: whether `block close` needs the coder's `TESTS <command> exit=0` line — true only when the
200
+ * snapshot in force sets `context.require_coder_tests: true` (false for the key absent or false,
201
+ * or a record from before snapshots). The key is immutable ({@link IMMUTABLE_KEYS}), so this is
202
+ * the value `run start` stored; the live config is never read.
203
+ * @param {Record<string, any>} record
204
+ * @returns {boolean}
205
+ * @throws {StateError} `config-snapshot` when the snapshot in force does not match its hash.
206
+ */
207
+ export function requireCoderTests(record) {
208
+ const context = configInForce(record)?.context;
209
+ return isMapping(context) && context.require_coder_tests === true;
210
+ }
211
+
212
+ /** The default `hooks.on_block_end.timeout_s`. */
213
+ export const HOOK_TIMEOUT_DEFAULT_S = 60;
214
+ /** The schema's bounds of `hooks.on_block_end.timeout_s`. */
215
+ export const HOOK_TIMEOUT_MIN_S = 1;
216
+ export const HOOK_TIMEOUT_MAX_S = 600;
217
+
218
+ /**
219
+ * `hooks.on_block_end` of the run-start config snapshot (never the live config), or null when it
220
+ * is absent or malformed (the schema refuses a malformed one at `run start`).
221
+ * @param {Record<string, any>} record
222
+ * @returns {{argv: string[], timeoutS: number} | null}
223
+ */
224
+ export function blockEndHook(record) {
225
+ let hook;
226
+ try {
227
+ hook = configInForce(record)?.hooks?.on_block_end;
228
+ } catch {
229
+ return null;
230
+ }
231
+ if (hook === null || typeof hook !== 'object') return null;
232
+ const { argv, timeout_s: timeout } = hook;
233
+ if (!Array.isArray(argv) || argv.length === 0 || !argv.every((a) => typeof a === 'string' && a.length > 0)) return null;
234
+ const timeoutS = timeout === undefined ? HOOK_TIMEOUT_DEFAULT_S : timeout;
235
+ if (!Number.isInteger(timeoutS) || timeoutS < HOOK_TIMEOUT_MIN_S || timeoutS > HOOK_TIMEOUT_MAX_S) return null;
236
+ return { argv: [...argv], timeoutS };
237
+ }
@@ -34,6 +34,10 @@
34
34
  * vs `src/**` and vs `src/feature/*.swift` overlap, vs `src/*.mjs` and `src/featureX/*` do not);
35
35
  * a `file` entry against a glob overlaps only when the glob matches it (`src/README` vs
36
36
  * `src/**\/*.mjs` does not).
37
+ *
38
+ * B62a — between two OPEN BLOCKS the rule is `blocksOverlap`: two blocks that each have their own
39
+ * worktree (`proof.isolation: worktree`) may own the same path; any pair with a shared-tree block
40
+ * in it is checked with `findOverlap` exactly as above.
37
41
  */
38
42
 
39
43
  import { StateError } from './paths.mjs';
@@ -99,21 +103,24 @@ export function expandBraces(pattern) {
99
103
 
100
104
  /**
101
105
  * @param {unknown} owned
106
+ * @param {{label?: string, code?: string}} [as] - B60: `block open --tests` checks its test files by
107
+ * the same rules, named `test files` with the code `bad-tests`.
102
108
  * @returns {string[]}
103
109
  * An EXACT entry (no `*`, `?` or `{`) may contain `[ ] ( ) ! + @ }` — real routes such as
104
110
  * `pages/[id].vue`, `app/(group)/page.tsx`, `@types/x.d.ts` — and is matched literally by
105
111
  * string equality. A GLOB entry may not: there those characters would be character-class or
106
112
  * extglob syntax this matcher does not implement, so it is refused with that reason rather than
107
113
  * silently read one way by us and another way by git or a shell.
108
- * @throws {StateError} `bad-owned` — empty, absolute, `.`/`..`/empty segments (checked on every
114
+ * @throws {StateError} with code `as.code` (default `bad-owned`) and a message that starts with
115
+ * `as.label` (default `owned files`) — empty, absolute, `.`/`..`/empty segments (checked on every
109
116
  * brace alternative), backslashes, unbalanced braces, or unsupported glob syntax in a glob.
110
117
  */
111
- export function assertOwned(owned) {
112
- if (!Array.isArray(owned) || owned.length === 0) throw new StateError('bad-owned', 'owned files: at least one path is required');
118
+ export function assertOwned(owned, { label = 'owned files', code = 'bad-owned' } = {}) {
119
+ if (!Array.isArray(owned) || owned.length === 0) throw new StateError(code, `${label}: at least one path is required`);
113
120
  for (const entry of owned) {
114
121
  /** @param {string} why */
115
122
  const refuse = (why) => {
116
- throw new StateError('bad-owned', `owned files: invalid path ${JSON.stringify(entry)}: ${why}`);
123
+ throw new StateError(code, `${label}: invalid path ${JSON.stringify(entry)}: ${why}`);
117
124
  };
118
125
  if (typeof entry !== 'string' || entry.length === 0) refuse('must be a non-empty string');
119
126
  if (entry.includes('\\')) refuse('backslashes are not allowed (POSIX paths only)');
@@ -240,6 +247,30 @@ export function findOverlap(ownedA, ownedB, { kindsA, kindsB } = {}) {
240
247
  return null;
241
248
  }
242
249
 
250
+ /**
251
+ * One open block as the overlap rule sees it: its owned entries, their kinds (B57) and its own
252
+ * worktree, if any (B62a). `worktree` is the run record's `{path, branch, base_sha}` (or, for a
253
+ * block being opened, `{path}` of the tree it is about to get); `null` or absent means it works in
254
+ * the shared tree (export or lock isolation). Every caller passes this one shape.
255
+ * @typedef {{owned_files: ReadonlyArray<string>, owned_kinds?: OwnedKinds, worktree?: {path: string} | null}} OverlapBlock
256
+ */
257
+
258
+ /**
259
+ * B62a: the owned-files overlap rule between two open blocks. Two blocks that EACH have their own
260
+ * worktree, at different paths, may own the same path — each edits its own copy on its own branch,
261
+ * and nothing is merged by code-forge — so the pair never overlaps. Two blocks on the SAME tree
262
+ * path edit one tree, and a block without a worktree (export or lock isolation) edits the shared
263
+ * tree: both are checked exactly as before B62a ({@link findOverlap}, B57's kinds included).
264
+ * @param {OverlapBlock} a @param {OverlapBlock} b
265
+ * @returns {[string, string] | null} the first overlapping pair (a's entry, b's entry), or null.
266
+ */
267
+ export function blocksOverlap(a, b) {
268
+ const treeA = a.worktree ?? null;
269
+ const treeB = b.worktree ?? null;
270
+ if (treeA !== null && treeB !== null && treeA.path !== treeB.path) return null;
271
+ return findOverlap(a.owned_files, b.owned_files, { kindsA: a.owned_kinds, kindsB: b.owned_kinds });
272
+ }
273
+
243
274
  /**
244
275
  * Does `owned` own repo-relative `file`? A directory-like exact entry owns its path and every path
245
276
  * below it, a `file` one (per `kinds`) only itself (B57); a glob entry owns what it matches. A
package/src/state/run.mjs CHANGED
@@ -217,7 +217,9 @@ export async function startRun(opts) {
217
217
  * Fixed keys ({@link IMMUTABLE_KEYS}): every one that changed is refused at once
218
218
  * (`immutable-key`, nothing written). A record from before snapshots (no `config`) is checked
219
219
  * against what it does store: `engine` and the ledger slug (`effectiveSlug`, computed by the
220
- * caller the same way `run start` did), and B58's `review.external.required` must not turn true;
220
+ * caller the same way `run start` did), B58's `review.external.required` must not turn true,
221
+ * B62a's `proof.isolation` must not turn to `worktree`, and B63b's `context.require_coder_tests`
222
+ * must not turn true;
221
223
  * it then gets its first snapshot, `changed_keys: null`, `old_hash: null`.
222
224
  *
223
225
  * No change (same hash) ⇒ nothing written, no row. Otherwise the record is saved FIRST (it is the
@@ -260,6 +262,13 @@ export async function reloadRun({ runId, config, readPending = () => [], effecti
260
262
  // B58: a run started without the key has none to keep — turning it on now is a change; an
261
263
  // entry that leaves it off (`required` false or absent, as the schema default) is not
262
264
  if (config.review?.external?.required === true) frozen.push('review.external');
265
+ // B62a: a run started without a snapshot was isolated by export (or lock); turning worktree
266
+ // isolation on now is a change
267
+ if (config.proof?.isolation === 'worktree') frozen.push('proof.isolation');
268
+ // B63b: likewise, a run started without a snapshot never required the coder's tests
269
+ if (config.context?.require_coder_tests === true) frozen.push('context.require_coder_tests');
270
+ // B64b: and never had a block-end hook
271
+ if (config.hooks?.on_block_end !== undefined) frozen.push('hooks.on_block_end');
263
272
  }
264
273
  if (frozen.length > 0) {
265
274
  const why = frozen.map((key) => `${key} cannot change mid-run (${IMMUTABLE_KEYS[/** @type {keyof typeof IMMUTABLE_KEYS} */ (key)]})`);
@@ -27,6 +27,7 @@ import path from 'node:path';
27
27
  export const SUBCOMMANDS = Object.freeze({
28
28
  autopilot: ['start', 'status', 'stop'],
29
29
  block: ['open', 'attempt', 'rebase', 'claim', 'close', 'stop', 'waive'],
30
+ context: ['pack'],
30
31
  gates: ['detect', 'run', 'secret-scan', 'safe-edit', 'scope', 'acceptance', 'transcript-grep'],
31
32
  jev: ['ask'],
32
33
  keys: ['list', 'set', 'test', 'remove'],
@@ -276,8 +276,11 @@ export const FORBIDDEN = Object.freeze(
276
276
  * They are NOT part of `FORBIDDEN` (whose rendered count other blocks pin); `mergeForbidden()`
277
277
  * always appends them, so every list built for a coder carries them.
278
278
  * - `code-forge block waive` — waiving a review finding is human-only;
279
+ * - `code-forge block reopen` (B72) — restarting a stopped or lost file's review is human-only;
279
280
  * - `code-forge autopilot` (any subcommand, B45) — the owner's grant to a delegate;
280
- * - `--no-require-reviews` — the human's way out of `block close`'s per-file review check.
281
+ * - `--no-require-reviews` — the human's way out of `block close`'s per-file review check;
282
+ * - `--remove-worktree` (B62a) — `block close` removing the block's worktree, the orchestrator's
283
+ * alone. `block close` itself is not on this list, so the flag is listed on its own.
281
284
  * @type {ReadonlyArray<ForbiddenEntry>}
282
285
  */
283
286
  export const CODER_ONLY_FORBIDDEN = Object.freeze(
@@ -291,6 +294,15 @@ export const CODER_ONLY_FORBIDDEN = Object.freeze(
291
294
  ],
292
295
  description: 'Waive a review finding (forge block waive) — human-only, run by the orchestrator after the human said so (§4.9)',
293
296
  },
297
+ {
298
+ id: 'code-forge-block-reopen-from-coder',
299
+ kind: 'anyPrefix',
300
+ prefixes: [
301
+ ['code-forge', 'block', 'reopen'],
302
+ ['forge', 'block', 'reopen'],
303
+ ],
304
+ description: 'Reopen the review of a stopped or lost file (forge block reopen) — human-only, run by the orchestrator after the human said so (B72)',
305
+ },
294
306
  {
295
307
  id: 'code-forge-autopilot-from-coder',
296
308
  kind: 'anyPrefix',
@@ -306,6 +318,12 @@ export const CODER_ONLY_FORBIDDEN = Object.freeze(
306
318
  tokens: ['--no-require-reviews'],
307
319
  description: 'Close a block without the per-file review check (--no-require-reviews) — human-only',
308
320
  },
321
+ {
322
+ id: 'code-forge-remove-worktree',
323
+ kind: 'contains',
324
+ tokens: ['--remove-worktree'],
325
+ description: "Remove a block's worktree at close (block close --remove-worktree, B62a) — the orchestrator only",
326
+ },
309
327
  ].map(freezeEntry),
310
328
  );
311
329
 
@@ -342,6 +360,33 @@ export function mergeForbidden(extraTokens = []) {
342
360
  return [...FORBIDDEN, ...CODER_ONLY_FORBIDDEN, ...extras];
343
361
  }
344
362
 
363
+ /** B64b: the id of the entry {@link hookForbidden} builds. */
364
+ export const HOOK_FORBIDDEN_ID = 'code-forge-on-block-end-hook';
365
+
366
+ /**
367
+ * B64b: the owner's `hooks.on_block_end` command as a coder-forbidden `prefix` entry — the hook
368
+ * runs only from the orchestrator's block-end verbs, never for a coder. The tokens are TRIMMED:
369
+ * the command name of `argv[0]` (its basename, as every reading normalizes it), plus `argv[1]`
370
+ * when there is one that is not blank (clause 3, "if any"). `argv[1]` stays ONE token even with
371
+ * inner whitespace — a literal match, as ruled. No entry (`[]`, so a list built with it is
372
+ * unchanged) when there is no hook (`argv` absent, empty, or not all non-empty strings), or when
373
+ * `argv[0]` is blank, names a directory (`/`, `dir/`), or is `.` or `..`. Appended by the caller
374
+ * after the static entries; it never replaces or weakens one.
375
+ * @param {unknown} argv
376
+ * @returns {ForbiddenEntry[]}
377
+ */
378
+ export function hookForbidden(argv) {
379
+ if (!Array.isArray(argv) || argv.length === 0 || !argv.every((a) => typeof a === 'string' && a.length > 0)) return [];
380
+ const first = argv[0].trim();
381
+ // a command name that is blank, a directory (`/`, `dir/`), `.` or `..` matches nothing a coder runs
382
+ if (first.length === 0 || /[/\\]$/.test(first)) return [];
383
+ const name = commandName(first).trim();
384
+ if (name.length === 0 || name === '.' || name === '..') return [];
385
+ const arg = argv.length > 1 ? argv[1].trim() : '';
386
+ const tokens = arg.length > 0 ? [name, arg] : [name];
387
+ return [freezeEntry({ id: HOOK_FORBIDDEN_ID, kind: 'prefix', tokens, description: 'Run the configured on_block_end hook (hooks.on_block_end) — it runs only at a block end, from the orchestrator\'s verbs' })];
388
+ }
389
+
345
390
  // ── Normalization ────────────────────────────────────────────────────────────
346
391
 
347
392
  /** git global options that take the NEXT argv token as their value (when not written `--opt=value`). */
@@ -0,0 +1,8 @@
1
+ /**
2
+ * The one argv item a gate uses for the block's related tests. It lives in `util` because two
3
+ * layers need the same value: `gates/expand.mjs` expands it, and `config/validate.mjs` refuses it as
4
+ * a gate's command (`config` may not import `gates`).
5
+ */
6
+
7
+ /** A gate argv item that is EXACTLY this string expands to the block's test files. */
8
+ export const TEST_FILES_TOKEN = '{test_files}';