@codedology/code-forge 0.1.0 → 0.2.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.
package/CHANGELOG.md CHANGED
@@ -4,6 +4,34 @@ All notable changes to `@codedology/code-forge` are recorded here. Format loosel
4
4
  [Keep a Changelog](https://keepachangelog.com/); versions follow semver ahead of a 1.0.0 that
5
5
  waits on a second real consumer (plan §10.4, Q17).
6
6
 
7
+ ## [Unreleased]
8
+
9
+ ## [0.2.0] — 2026-09-26
10
+
11
+ ### Added
12
+
13
+ - **`code-forge review`** — review only: runs the review engine on changes you already have (your
14
+ branch against the merge base with the default branch, your uncommitted work, or `--files`), with
15
+ no plan, coder or proof, and never closes a block. One verdict per file, a fix list, totals or one
16
+ JSON document (`--json`); exit 0 only when every file is approved. The run and its worker are
17
+ always ended, also on Ctrl-C, SIGTERM and timeouts. See `docs/review-only.md`.
18
+ - **Release tools:** `npm run release -- <patch|minor|major|x.y.z>` bumps every version location,
19
+ moves the changelog, runs every check (with rollback), commits and tags; `npm run changelog --
20
+ <type> "text"` records changes as you go. See `docs/releasing.md`.
21
+ - **GitHub Releases:** a tag push publishes to npm and then creates the GitHub Release from the
22
+ changelog, with links to the npm version and the changelog.
23
+
24
+ ### Changed
25
+
26
+ - **Reviewers see the acceptance clauses.** Every review packet now carries the block's acceptance
27
+ clauses (redacted, cut to fit the packet budget); a block without readable clauses is still
28
+ reviewed, with `(acceptance unavailable)`.
29
+
30
+ ### Fixed
31
+
32
+ - CI is green on Linux: a macOS-only case-insensitivity test now skips on case-sensitive
33
+ filesystems, and the Node 22/24 matrix no longer cancels one job when the other fails.
34
+
7
35
  ## [0.1.0] — first release
8
36
 
9
37
  The `plan` → `harden` → `code` → `review` pipeline for cross-model, parallel, evidence-gated
package/README.md CHANGED
@@ -10,8 +10,8 @@ for cross-model, parallel, evidence-gated feature delivery — on Claude Code, C
10
10
 
11
11
  ## What ships
12
12
 
13
- - A CLI (`code-forge`), installed globally via `npx @codedology/code-forge init` into every detected
14
- harness (Claude Code, Codex, Grok, …).
13
+ - A CLI (`code-forge`), installed globally with `npm install -g @codedology/code-forge`; `code-forge
14
+ init` then links the skill into every detected harness (Claude Code, Codex, Grok, …).
15
15
  - One Agent Skill (`skill/SKILL.md` + `references/`) that is prose only — every fact (model ids,
16
16
  efforts, commands, thresholds, paths) lives in config or in the CLI, never in the skill text.
17
17
  - A JSON Schema for `.code-forge.yml` (`schema/code-forge.schema.json`), documented in
@@ -23,14 +23,18 @@ policy and security model; `docs/reference/blocks.json` lists the build blocks.
23
23
  ## Install
24
24
 
25
25
  ```bash
26
- npx @codedology/code-forge init
26
+ npm install -g @codedology/code-forge
27
+ code-forge init
27
28
  ```
28
29
 
30
+ The scoped name is used only to install; the command is `code-forge`. The unscoped npm package
31
+ `code-forge` is a different, unrelated tool.
32
+
29
33
  The wizard asks nine short questions (tools, harnesses, provider matrix, multimodel, keys, engine,
30
34
  gates, proof, doctor), every one with a default on Enter. Non-interactively:
31
35
 
32
36
  ```bash
33
- npx @codedology/code-forge init --no-interaction --no-jev
37
+ code-forge init --no-interaction --no-jev
34
38
  ```
35
39
 
36
40
  `--no-jev` skips the System-1 key for a first try; drop it and pass `--jev-ref op://vault/item/field`
@@ -51,6 +55,20 @@ code-forge --help # every verb, one per line
51
55
  code-forge version
52
56
  ```
53
57
 
58
+ ## Review only
59
+
60
+ To review code you already wrote — no plan, no coder, no proof — run this in the repository:
61
+
62
+ ```bash
63
+ code-forge review # verdict + fix list per changed file; exit 0 only if all approved
64
+ ```
65
+
66
+ It reviews your branch against the merge base with the default branch (on the default branch: your
67
+ uncommitted work), with the same fresh, closed-book reviewer sessions a block gets. Narrow it with
68
+ `--files <path…>`, state the change with `--intent "<text>"`, pick the base with `--base <ref>`, or
69
+ get one JSON document with `--json`. Exit codes: `0` all approved or nothing to review, `1` any
70
+ finding, stop or unavailable review, `2` usage. See [docs/review-only.md](docs/review-only.md).
71
+
54
72
  ## The verbs
55
73
 
56
74
  Verbs are discovered from `src/cli/*.mjs` — adding one never touches the router
@@ -66,6 +84,7 @@ usage line.
66
84
  | `run` | `code-forge run start [--cwd <dir>] [--run <id>] [--engine <e>] [--worker-pid <pid>]` · `run start --reattach --run <id>` · `run status --run <id>` · `run end --run <id>` |
67
85
  | `block` | `code-forge block open <id> --run <r> --level L<n> --owned <paths…> --acceptance <file>` · `block attempt\|rebase\|claim\|close\|stop\|waive …` (§4.9; `waive` is human-only) |
68
86
  | `worker` | `code-forge worker --run <id> [--cwd <dir>] [--poll-ms <n>] [--once]` — started detached by `run start`, never by a coder |
87
+ | `review` | `code-forge review [--base <ref>] [--files <path…>] [--acceptance <file> \| --intent "<text>"] [--run <id>] [--max <seconds>] [--json] [--keep-run]` — review only: no coder, no proof, never closes a block |
69
88
  | `review-file` | `code-forge review-file <path> --block <id> [--run <id>]` (enqueue) · `review-file --wait <ticket> [--max <seconds>s]` (poll) |
70
89
  | `spawn` | `code-forge spawn --level L<n> --role <role> --brief <file> [--schema <file>] [--cwd <dir>] [--run <id>] [--block <id>] [--timeout <s>] [--background]` |
71
90
  | `s2` | `code-forge s2 --packet <file.json> [--run <id>] [--block <id>] [--timeout <s>]` |
@@ -125,3 +144,5 @@ npm run typecheck # tsc --checkJs --noEmit
125
144
  Node >= 22 required (ruling R7). No build step — the package ships plain ESM `.mjs`, run directly
126
145
  by Node. `docs/reference/blocks.json` maps each build block to the files it owns and what it
127
146
  depends on, kept in sync with the maintainer's design plan.
147
+
148
+ Releasing: record changes with `npm run changelog -- <type> "<text>"`, then `npm run release -- <patch|minor|major|x.y.z> [--dry-run]` bumps, commits and tags (it never pushes or publishes). See [docs/releasing.md](docs/releasing.md).
package/docs/README.md CHANGED
@@ -13,10 +13,12 @@ disagree, the verb is right: each verb prints its own usage line when its argume
13
13
  | 3 | [how-it-works.md](how-it-works.md) | follow the full workflow from a brief to a closed block, and see who does what and where state lives |
14
14
  | 4 | [getting-started.md](getting-started.md) | install it, answer `init`, run `doctor`, and try the first commands |
15
15
  | 5 | [tutorial-first-test.md](tutorial-first-test.md) | run one real end-to-end test: build the code-forge marketing site in a new repo |
16
+ | 5a | [review-only.md](review-only.md) | only review code you already wrote: a branch, uncommitted work or some files, with CI exit codes |
16
17
  | 6 | [examples/marketing-site/brief.md](examples/marketing-site/brief.md) | copy the brief the tutorial uses |
17
18
  | 7 | [marketing.md](marketing.md) | read the product story: the problem, the promise, the honest limits |
18
19
 
19
- New to the tool? Read 1 → 3 → 4, then do 5. Keep 2 open as a glossary.
20
+ New to the tool? Read 1 → 3 → 4, then do 5. Keep 2 open as a glossary. Only want reviews? Read 4,
21
+ then 5a.
20
22
 
21
23
  ## Reference pages (already in the repo)
22
24
 
@@ -25,6 +27,7 @@ New to the tool? Read 1 → 3 → 4, then do 5. Keep 2 open as a glossary.
25
27
  | [reference/config.md](reference/config.md) | every `.code-forge.yml` key, its type and default. Generated from `schema/code-forge.schema.json` |
26
28
  | [reference/blocks.json](reference/blocks.json) | the build blocks of this package and what each one owns |
27
29
  | [marketing/code-forge-story.md](marketing/code-forge-story.md) | the marketing content bank (copy, lessons, what not to claim) |
30
+ | [releasing.md](releasing.md) | for maintainers: record changes (`npm run changelog`), cut a release with one command (`npm run release`), GitHub Releases, what stays manual |
28
31
  | [`skill/SKILL.md`](../skill/SKILL.md) and [`skill/references/`](../skill/references/) | the Agent Skill the orchestrator follows. The deepest and most exact description of the rules |
29
32
 
30
33
  ## Conventions in these pages
@@ -1,10 +1,10 @@
1
1
  {
2
- "$comment": "hand-maintained; checked against plan §10.4 + NIGHT-LOG by test/docs-blocks.test.mjs. Ids = every `| **Bxx** |` row in plans/code-forge-plan-v1.3.md §10.4 Waves 2-6 (19 ids), plus the blocks the root orchestrator added during the night run of 2026-09-25 that are not in that table (plans/NIGHT-LOG.md): B1.2, B4.1, B11.1, B12c (B3.1 was already in the v1.3 §10.4 table). Wave 0-1 blocks (B0-B8) are the pre-v1.3 baseline landed before this plan revision; they have no entry under blocks and are listed in baseline_ids so every depends_on id resolves; see plans/code-forge-plan-v1.3.md §10.2-§10.3 for their record. B10c is listed with deleted:true — the maintainer answered Q16 'cut' on 2026-09-25, so B10c (consumer-facing mutation-testing tools) never lands.",
2
+ "$comment": "hand-maintained; checked against plan §10.4 + NIGHT-LOG by test/docs-blocks.test.mjs. Ids = every `| **Bxx** |` row in plans/code-forge-plan-v1.3.md §10.4 Waves 2-6 (19 ids), plus the blocks the root orchestrator added during the night run of 2026-09-25 that are not in that table (plans/NIGHT-LOG.md): B1.2, B4.1, B11.1, B12c (B3.1 was already in the v1.3 §10.4 table); plus B22 (the review-only verb), which the root added on 2026-09-26 after the night run. Wave 0-1 blocks (B0-B8) are the pre-v1.3 baseline landed before this plan revision; they have no entry under blocks and are listed in baseline_ids so every depends_on id resolves; see plans/code-forge-plan-v1.3.md §10.2-§10.3 for their record. B10c is listed with deleted:true — the maintainer answered Q16 'cut' on 2026-09-25, so B10c (consumer-facing mutation-testing tools) never lands.",
3
3
  "checked_against": [
4
4
  "plans/code-forge-plan-v1.3.md §10.4",
5
5
  "plans/NIGHT-LOG.md"
6
6
  ],
7
- "block_count": 23,
7
+ "block_count": 24,
8
8
  "baseline_ids": ["B0", "B1", "B2", "B3", "B4", "B5", "B6", "B7", "B8"],
9
9
  "baseline_note": "Wave 0-1 blocks, landed before v1.3; depends_on may name them, but they have no entry under blocks.",
10
10
  "blocks": {
@@ -283,6 +283,17 @@
283
283
  "src/proof/tools/**", "src/proof/nightly.mjs", "ci/nightly-proof.yml", "test/proof/tools/**", "test/proof/nightly.test.mjs"
284
284
  ],
285
285
  "depends_on": ["B10a", "B10b"]
286
+ },
287
+ "B22": {
288
+ "title": "Review-only verb: code-forge review (base/file discovery, run start -> block open -> review-file -> block stop -> run end) and its docs",
289
+ "wave": 6,
290
+ "level": "L2",
291
+ "status": "in_review",
292
+ "owned": [
293
+ "src/cli/review.mjs", "test/cli/review.test.mjs", "docs/review-only.md"
294
+ ],
295
+ "depends_on": ["B11", "B12a", "B12c"],
296
+ "note": "root-added 2026-09-26 (after the night run); not in the v1.3 §10.4 table"
286
297
  }
287
298
  }
288
299
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@codedology/code-forge",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "Plan/harden/code/review orchestration CLI and Agent Skill for cross-model, parallel, evidence-gated feature delivery.",
5
5
  "repository": {
6
6
  "type": "git",
@@ -29,13 +29,17 @@
29
29
  "docs/reference",
30
30
  "examples",
31
31
  "scripts",
32
+ "!scripts/release.mjs",
33
+ "!scripts/changelog.mjs",
32
34
  "README.md",
33
35
  "CHANGELOG.md",
34
36
  "LICENSE"
35
37
  ],
36
38
  "scripts": {
37
39
  "test": "CODE_FORGE_NO_LEAK=snapshot node --import ./test/helpers/isolate.mjs --test test/no-leak.test.mjs && node --import ./test/helpers/isolate.mjs --experimental-test-module-mocks --test 'test/**/*.test.mjs' && CODE_FORGE_NO_LEAK=final node --import ./test/helpers/isolate.mjs --test test/no-leak.test.mjs",
38
- "typecheck": "tsc --checkJs --noEmit"
40
+ "typecheck": "tsc --checkJs --noEmit",
41
+ "release": "node scripts/release.mjs",
42
+ "changelog": "node scripts/changelog.mjs"
39
43
  },
40
44
  "dependencies": {
41
45
  "@clack/prompts": "1.8.1",
@@ -40,3 +40,7 @@ Two L2 reviewers, one per effective provider (`resolve(L2)` and `review.second_l
40
40
  ## §7 The review budget per block
41
41
 
42
42
  `review.block_budget_tokens` (and optionally `review.block_budget_usd`) bounds every review, recheck, judge and S2 session made for the block. When the forecast exceeds it the worker degrades one step at a time, cheapest first: batch small files of one tier into one packet (`review.batch_small_files`) → one judge per block instead of per file → standard band `full` → `quick` → high tier `A + B + judge` → one `full` L2 + judge, the floor. Tools-only is never a review; a light `quick` stays `quick`. Still over ⇒ `review.over_budget`: raise the budget once per run (a config edit by the human, on record) or split the block — never silently, never below the floors. The ledger keeps `depth_unconstrained` and `depth_chosen` per file so `forge report` can show `missed_after_degrade`.
43
+
44
+ ## §8 Review only
45
+
46
+ A human who wants a review and nothing else runs `forge review`: it reviews the changed files in one `R-<timestamp>` block at L2, prints the verdicts, then stops the block and ends the run — no plan, no coder, no proof, never a close. Inside a block the orchestrator keeps using `forge review-file`.
@@ -10,7 +10,7 @@
10
10
  # copied install with no package next to it); `test/skill/shim.test.mjs` asserts the pin
11
11
  # equals package.json's version.
12
12
  # The shim holds no logic of its own: every verb, flag and message is the CLI's.
13
- PINNED_VERSION="0.1.0"
13
+ PINNED_VERSION="0.2.0"
14
14
  set -eu
15
15
 
16
16
  self="$0"
@@ -0,0 +1,610 @@
1
+ /**
2
+ * `code-forge review` (block B22) — review-only: the review engine on changes you already have,
3
+ * with no plan, no coder, no proof and no block close.
4
+ *
5
+ * review [--base <ref>] [--files <path…>] [--acceptance <file> | --intent "<text>"]
6
+ * [--run <id>] [--max <seconds>] [--json] [--keep-run]
7
+ *
8
+ * 1. Base: `git merge-base HEAD <default>`, where `<default>` is `origin/HEAD`'s branch, else
9
+ * `main`, else `master`; when that is HEAD itself (you are on the default branch), the base is
10
+ * HEAD and the uncommitted changes are reviewed. `--base <ref>` overrides. Resolved to a sha.
11
+ * 2. Files: `git diff --name-only <base>` (committed, staged and unstaged changes against the
12
+ * working tree) plus untracked files git does not ignore. Deleted, binary, symlinked and
13
+ * secret-like files are skipped, and so is code-forge's own state (`.code-forge/`,
14
+ * `.code-forge.yml`). `--files` replaces the list; each path is checked before anything starts.
15
+ * 3. Acceptance: `--acceptance <file>` as given; `--intent` becomes one clause; neither ⇒ one
16
+ * default clause. A generated file lives in this command's temp root, never in the tree.
17
+ * 4. Flow, every step a `code-forge` child with a `GIT_*`-free env: `run start` (launches the
18
+ * worker) → `block open R-<stamp> --level L2 --base <sha> --owned <files…>` → `review-file`
19
+ * per file → `review-file --wait` per ticket (`--max`, default 900 s per file) → summary →
20
+ * `block stop` (a review never closes a block) → `run end` unless `--keep-run`. The cleanup runs
21
+ * in a `finally`, on SIGINT/SIGTERM and after a timeout, and no worker outlives the command.
22
+ * Each cleanup step is independent (a failed or throwing step is reported on stderr and the
23
+ * next one still runs), and a cleanup error never replaces the review's own exit code.
24
+ * `--run <id>` names the run: an active run with that id is reused (and never ended here);
25
+ * otherwise a new run starts with that id.
26
+ * Exit codes: 0 every file approved (or nothing to review); 1 any file with findings, stopped,
27
+ * unavailable or timed out, or the flow failed; 2 usage, not a git repository, a bad base, a bad
28
+ * path, or no `.code-forge.yml`.
29
+ *
30
+ * `--files` paths go through `review-file`'s own path rule (`normalizeRequestPath`) before anything
31
+ * starts, and `review-file` re-checks each one when it enqueues it.
32
+ */
33
+
34
+ import { randomBytes } from 'node:crypto';
35
+ import { closeSync, lstatSync, openSync, readFileSync, readSync, rmSync, writeFileSync } from 'node:fs';
36
+ import path from 'node:path';
37
+ import { fileURLToPath } from 'node:url';
38
+ import { parse as parseYAML } from 'yaml';
39
+ import { loadProjectConfig } from '../config/load.mjs';
40
+ import { computeFileSet } from '../gates/scope.mjs';
41
+ import { parseFlags } from '../state/cli-args.mjs';
42
+ import { assertRunId, newRunId, StateError } from '../state/paths.mjs';
43
+ import { processStartTime, readRun } from '../state/run.mjs';
44
+ import { exec } from '../util/exec.mjs';
45
+ import { writeSafe } from '../util/redact.mjs';
46
+ import { isAlive, listEntries, readStartTime, UNKNOWN_START_TIME } from '../util/reaper.mjs';
47
+ import { pidsDir, runRoot, setRunRoot, tmpBase } from '../util/tmp.mjs';
48
+ import { readWorker } from '../worker/queue.mjs';
49
+ import { gitChildEnv, normalizeRequestPath, repoRootOf, WorkerError } from '../worker/ticket.mjs';
50
+
51
+ const BIN = fileURLToPath(new URL('../../bin/code-forge.mjs', import.meta.url));
52
+
53
+ export const USAGE =
54
+ 'usage: code-forge review [--base <ref>] [--files <path…>] [--acceptance <file> | --intent "<text>"] [--run <id>] [--max <seconds>] [--json] [--keep-run]\n';
55
+
56
+ export const DEFAULT_MAX_S = 900;
57
+ export const DEFAULT_CLAUSE = 'Review for correctness, security and test quality; no intent was stated.';
58
+ const GIT_TIMEOUT_MS = 30_000;
59
+ const STEP_TIMEOUT_MS = 120_000;
60
+
61
+ /** Never sent to a reviewer (the packet refuses them too): env files, keys, certificates, credentials. */
62
+ const SECRET_LIKE = [/(^|\/)\.env[^/]*$/i, /\.pem$/i, /\.key$/i, /\.p12$/i, /(^|\/)id_(rsa|ed25519|ecdsa)[^/]*$/i, /(^|\/)credentials[^/]*$/i];
63
+
64
+ /** A refusal that exits 2 (usage, not a repo, bad base, bad path, no config). */
65
+ class UsageError extends Error {}
66
+ /** The run was interrupted (signal). */
67
+ class Interrupted extends Error {}
68
+
69
+ /** `process.env` minus every inherited `GIT_*` (a hook's `GIT_DIR` must never redirect a child). */
70
+ export function scrubbedEnv() {
71
+ /** @type {Record<string, string>} */
72
+ const env = {};
73
+ for (const [key, value] of Object.entries(process.env)) if (!key.startsWith('GIT_') && value !== undefined) env[key] = value;
74
+ return env;
75
+ }
76
+
77
+ /**
78
+ * @param {string[]} args @param {string} cwd @param {number[]} [ok]
79
+ * @returns {Promise<import('../util/exec.mjs').ExecResult>}
80
+ */
81
+ const git = (args, cwd, ok) => exec(['git', ...args], { cwd, env: gitChildEnv(), timeoutMs: GIT_TIMEOUT_MS, ...(ok ? { okExitCodes: ok } : {}) });
82
+
83
+ /** @param {string} ref @param {string} cwd @returns {Promise<string | null>} the commit sha, or null. */
84
+ async function commitOf(ref, cwd) {
85
+ if (ref.length === 0 || ref.startsWith('-')) return null;
86
+ const res = await git(['rev-parse', '--verify', '--quiet', `${ref}^{commit}`], cwd);
87
+ const sha = res.stdout.trim();
88
+ return res.result === 'ok' && /^[0-9a-f]{40,64}$/.test(sha) ? sha : null;
89
+ }
90
+
91
+ /**
92
+ * The base sha and how it was chosen.
93
+ * @param {string} repoRoot @param {string | undefined} given
94
+ * @returns {Promise<{sha: string, from: string}>}
95
+ * @throws {UsageError}
96
+ */
97
+ export async function resolveBase(repoRoot, given) {
98
+ if (given !== undefined) {
99
+ const sha = await commitOf(given, repoRoot);
100
+ if (sha === null) throw new UsageError(`--base ${JSON.stringify(given)} is not a commit of this repository`);
101
+ return { sha, from: given };
102
+ }
103
+ const head = await commitOf('HEAD', repoRoot);
104
+ if (head === null) throw new UsageError('the repository has no commit yet — commit once, or pass --base');
105
+ const candidates = [];
106
+ const originHead = await git(['symbolic-ref', '--quiet', '--short', 'refs/remotes/origin/HEAD'], repoRoot, [0, 1]);
107
+ if (originHead.result === 'ok' && originHead.code === 0 && originHead.stdout.trim().length > 0) candidates.push(originHead.stdout.trim());
108
+ candidates.push('main', 'master');
109
+ for (const name of candidates) {
110
+ const tip = await commitOf(name, repoRoot);
111
+ if (tip === null) continue;
112
+ const mb = await git(['merge-base', 'HEAD', tip], repoRoot);
113
+ const sha = mb.stdout.trim();
114
+ if (mb.result !== 'ok' || sha.length === 0) throw new UsageError(`HEAD and ${name} share no history — pass --base <ref>`);
115
+ return sha === head ? { sha: head, from: 'HEAD (uncommitted changes)' } : { sha, from: `merge-base HEAD ${name}` };
116
+ }
117
+ return { sha: head, from: 'HEAD (uncommitted changes; no main, master or origin/HEAD)' };
118
+ }
119
+
120
+ /**
121
+ * A `--files` path through `review-file`'s own rule (`normalizeRequestPath`: relative to `cwd`, no
122
+ * `..` segment, not absolute, inside the repository after resolving the directory's real path,
123
+ * never under `.git/` or `.code-forge/`, never a symlink).
124
+ * @param {string} raw @param {string} cwd @param {string} repoRoot - realpath'd
125
+ * @returns {string} the repo-root-relative POSIX path
126
+ * @throws {UsageError}
127
+ */
128
+ export function checkRequestPath(raw, cwd, repoRoot) {
129
+ try {
130
+ return normalizeRequestPath(raw, cwd, repoRoot);
131
+ } catch (err) {
132
+ if (err instanceof WorkerError) throw new UsageError(`--files ${JSON.stringify(raw)}: ${err.message}`);
133
+ throw err;
134
+ }
135
+ }
136
+
137
+ /**
138
+ * Why a candidate file is not sent for review, or null when it is.
139
+ * @param {string} repoRoot @param {string} rel @param {{discovered: boolean}} opts
140
+ * @returns {string | null}
141
+ */
142
+ export function skipReason(repoRoot, rel, { discovered }) {
143
+ const top = rel.split('/')[0].toLowerCase();
144
+ if (top === '.code-forge' || top === '.git') return 'code-forge state';
145
+ if (discovered && rel === '.code-forge.yml') return 'code-forge config';
146
+ if (rel.startsWith('-') || rel.includes('\\')) return 'unsupported file name';
147
+ if (SECRET_LIKE.some((re) => re.test(rel))) return 'secret-like name, never sent to a reviewer';
148
+ let st;
149
+ try {
150
+ st = lstatSync(path.join(repoRoot, rel));
151
+ } catch {
152
+ return 'deleted';
153
+ }
154
+ if (st.isSymbolicLink()) return 'symlink';
155
+ if (!st.isFile()) return 'not a regular file';
156
+ // git's own heuristic: a NUL byte in the first 8000 bytes means binary
157
+ let fd;
158
+ try {
159
+ fd = openSync(path.join(repoRoot, rel), 'r');
160
+ const head = Buffer.alloc(8000);
161
+ const n = readSync(fd, head, 0, head.length, 0);
162
+ if (head.subarray(0, n).includes(0)) return 'binary';
163
+ } catch {
164
+ return 'unreadable';
165
+ } finally {
166
+ if (fd !== undefined) closeSync(fd);
167
+ }
168
+ return null;
169
+ }
170
+
171
+ /**
172
+ * The acceptance clauses from a file the user gave.
173
+ * @param {string} file @returns {Array<{clause: string, tests: string[]}>}
174
+ * @throws {UsageError}
175
+ */
176
+ function readAcceptance(file) {
177
+ let parsed;
178
+ try {
179
+ parsed = parseYAML(readFileSync(file, 'utf8'), { prettyErrors: false });
180
+ } catch {
181
+ throw new UsageError(`--acceptance ${file}: not a readable YAML or JSON file`);
182
+ }
183
+ const ok =
184
+ Array.isArray(parsed) &&
185
+ parsed.length > 0 &&
186
+ parsed.every((c) => typeof c?.clause === 'string' && c.clause.length > 0 && Array.isArray(c.tests) && c.tests.length > 0 && c.tests.every((/** @type {unknown} */ t) => typeof t === 'string' && t.length > 0));
187
+ if (!ok) throw new UsageError(`--acceptance ${file}: must be a non-empty list of {clause, tests: [test ids…]}`);
188
+ return parsed;
189
+ }
190
+
191
+ /** @param {string} text @returns {any} the last line of `text` as JSON, or null. */
192
+ function lastJSON(text) {
193
+ try {
194
+ return JSON.parse(text.trim().split('\n').at(-1) ?? '');
195
+ } catch {
196
+ return null;
197
+ }
198
+ }
199
+
200
+ /**
201
+ * @typedef {{file: string, result: 'approved' | 'unchanged' | 'findings' | 'stopped' | 'unavailable', reason: string | null,
202
+ * findings: Array<{id: unknown, severity: unknown, lines: string, claim: string, fix: string}>}} FileOutcome
203
+ */
204
+
205
+ /**
206
+ * One file's outcome from its `review-file --wait` answer (or null when there was none).
207
+ * @param {string} file @param {Record<string, any> | null} waited
208
+ * @returns {FileOutcome}
209
+ */
210
+ export function classify(file, waited) {
211
+ const findings = Array.isArray(waited?.fix_list) ? waited.fix_list : [];
212
+ const out = (/** @type {FileOutcome['result']} */ result, /** @type {string | null} */ reason) => ({ file, result, reason, findings });
213
+ if (!waited) return out('unavailable', 'no answer');
214
+ if (waited.status === 'pending') return out('stopped', 'timeout');
215
+ if (waited.status !== 'done') return out('unavailable', String(waited.reason ?? waited.status ?? 'no answer'));
216
+ const r = waited.result;
217
+ if (!r || typeof r !== 'object') return out('unavailable', 'unreadable result');
218
+ if (r.approved === true) return out('approved', null);
219
+ if (r.status === 'no_change') return out('unchanged', null);
220
+ if (r.status === 'stopped') return out('stopped', String(r.stopped ?? 'stopped'));
221
+ if (r.status === 'split_required') return out('stopped', 'split_required');
222
+ if (r.status === 'reviewed' && findings.length > 0) return out('findings', null);
223
+ return out('unavailable', String(r.reason ?? r.status ?? 'not approved'));
224
+ }
225
+
226
+ /** @param {FileOutcome} o @returns {string} */
227
+ function label(o) {
228
+ if (o.result === 'findings') return `${o.findings.length} finding${o.findings.length === 1 ? '' : 's'}`;
229
+ if (o.result === 'stopped' || o.result === 'unavailable') return `${o.result}: ${o.reason}`;
230
+ return o.result;
231
+ }
232
+
233
+ /** @param {FileOutcome[]} outcomes */
234
+ export function totals(outcomes) {
235
+ const count = (/** @type {string} */ r) => outcomes.filter((o) => o.result === r).length;
236
+ return {
237
+ files: outcomes.length,
238
+ approved: count('approved'),
239
+ unchanged: count('unchanged'),
240
+ with_findings: count('findings'),
241
+ findings: outcomes.reduce((n, o) => n + (o.result === 'findings' ? o.findings.length : 0), 0),
242
+ stopped: count('stopped'),
243
+ unavailable: count('unavailable'),
244
+ };
245
+ }
246
+
247
+ /** @param {FileOutcome[]} outcomes @returns {string} the human summary. */
248
+ export function renderText(outcomes) {
249
+ const width = Math.max(...outcomes.map((o) => o.file.length));
250
+ const lines = outcomes.map((o) => `${o.file.padEnd(width)} ${label(o)}`);
251
+ for (const o of outcomes) {
252
+ if (o.result === 'approved' || o.result === 'unchanged' || o.findings.length === 0) continue;
253
+ lines.push('', `fix list: ${o.file}`);
254
+ for (const f of o.findings) lines.push(` ${f.id} · ${f.severity} · lines ${f.lines}`, ` claim: ${f.claim}`, ` fix: ${f.fix}`);
255
+ }
256
+ const t = totals(outcomes);
257
+ lines.push(
258
+ '',
259
+ `totals: ${t.files} file${t.files === 1 ? '' : 's'} · ${t.approved} approved · ${t.with_findings} with findings (${t.findings} finding${t.findings === 1 ? '' : 's'}) · ${t.stopped} stopped · ${t.unavailable} unavailable${t.unchanged > 0 ? ` · ${t.unchanged} unchanged` : ''}`,
260
+ );
261
+ return `${lines.join('\n')}\n`;
262
+ }
263
+
264
+ /**
265
+ * The worker pids this run left, each with the start time that proves it is still the same
266
+ * process: the pin in the run record and the queue's announcement for this run.
267
+ * @param {string} repoRoot @param {string} runId
268
+ * @returns {Promise<Array<{pid: number, same: () => Promise<boolean>}>>}
269
+ */
270
+ async function runWorkers(repoRoot, runId) {
271
+ const found = [];
272
+ try {
273
+ const pin = (await readRun(runId)).worker;
274
+ if (Number.isInteger(pin?.pid) && pin.pid > 1) found.push({ pid: pin.pid, same: async () => (await processStartTime(pin.pid)) === pin.started_at });
275
+ } catch {
276
+ // no record: nothing pinned
277
+ }
278
+ const w = readWorker(repoRoot);
279
+ if (w?.run === runId && !found.some((f) => f.pid === w.pid)) {
280
+ found.push({ pid: w.pid, same: async () => w.start_time === UNKNOWN_START_TIME || readStartTime(w.pid) === w.start_time });
281
+ }
282
+ return found;
283
+ }
284
+
285
+ /**
286
+ * After `run end` (which sends SIGTERM and returns): wait up to 5 s for this run's worker to be
287
+ * gone, then SIGKILL its process group. Also catches a worker a `run start` launched but never
288
+ * pinned because it was interrupted.
289
+ * @param {string} repoRoot @param {string} runId @returns {Promise<number>} workers killed here
290
+ */
291
+ async function reapWorkers(repoRoot, runId) {
292
+ let killed = 0;
293
+ for (const w of await runWorkers(repoRoot, runId)) {
294
+ if (!isAlive(w.pid) || !(await w.same())) continue;
295
+ try {
296
+ process.kill(w.pid, 'SIGTERM');
297
+ } catch {
298
+ continue;
299
+ }
300
+ const until = Date.now() + 5000;
301
+ while (isAlive(w.pid) && Date.now() < until) await new Promise((r) => setTimeout(r, 100));
302
+ if (isAlive(w.pid) && (await w.same())) {
303
+ try {
304
+ process.kill(-w.pid, 'SIGKILL');
305
+ } catch {
306
+ try {
307
+ process.kill(w.pid, 'SIGKILL');
308
+ } catch {
309
+ // gone
310
+ }
311
+ }
312
+ killed += 1;
313
+ }
314
+ }
315
+ return killed;
316
+ }
317
+
318
+ /** @param {unknown} err @returns {string} */
319
+ const messageOf = (err) => (err instanceof Error ? err.message : String(err));
320
+
321
+ /**
322
+ * @param {string[]} args
323
+ * @param {{stdout?: {write: (s: string) => unknown}, stderr?: {write: (s: string) => unknown}, cwd?: string, exec?: typeof exec}} [deps] -
324
+ * `exec` runs every `code-forge` child of this command (the flow steps, the cleanup and the
325
+ * second-signal exit); a test seam, the real `exec` otherwise.
326
+ * @returns {Promise<number>}
327
+ */
328
+ export async function runReview(args, deps = {}) {
329
+ const stdout = deps.stdout ?? process.stdout;
330
+ const stderr = deps.stderr ?? process.stderr;
331
+ const out = (/** @type {string} */ s) => writeSafe(stdout, s);
332
+ const note = (/** @type {string} */ s) => writeSafe(stderr, `review: ${s}\n`);
333
+ const cwd = deps.cwd ?? process.cwd();
334
+ const execChild = deps.exec ?? exec;
335
+
336
+ /** @type {Record<string, any>} */
337
+ let flags;
338
+ try {
339
+ ({ flags } = parseReviewArgs(args));
340
+ } catch (err) {
341
+ writeSafe(stderr, `review: ${err instanceof Error ? err.message : String(err)}\n${USAGE}`);
342
+ return 2;
343
+ }
344
+ if (flags.help === true) {
345
+ out(USAGE);
346
+ return 0;
347
+ }
348
+ const json = flags.json === true;
349
+ const maxS = flags.max === undefined ? DEFAULT_MAX_S : Number(String(flags.max).replace(/s$/, ''));
350
+
351
+ // ---- everything below until `run start` only reads: a refusal here exits 2 with no child started
352
+ let repoRoot;
353
+ let base;
354
+ /** @type {string[]} */
355
+ let files = [];
356
+ /** @type {Array<{file: string, reason: string}>} */
357
+ const skipped = [];
358
+ let cfg;
359
+ let acceptanceFile = null;
360
+ try {
361
+ repoRoot = await repoRootOf(cwd).catch(() => {
362
+ throw new UsageError('not inside a git repository');
363
+ });
364
+ base = await resolveBase(repoRoot, typeof flags.base === 'string' ? flags.base : undefined);
365
+
366
+ const given = Array.isArray(flags.files) ? flags.files.map((raw) => checkRequestPath(raw, cwd, repoRoot)) : null;
367
+ const candidates = given ? [...new Set(given)] : (await computeFileSet({ cwd: repoRoot, base: base.sha })).all;
368
+ for (const file of candidates) {
369
+ const why = skipReason(repoRoot, file, { discovered: given === null });
370
+ if (why === null) files.push(file);
371
+ else skipped.push({ file, reason: why });
372
+ }
373
+ files = files.sort();
374
+
375
+ if (files.length > 0) {
376
+ const loaded = await loadProjectConfig(repoRoot);
377
+ if (!loaded.ok) throw new UsageError(loaded.error === 'not-found' ? 'no .code-forge.yml at the repository root — run `code-forge init` first' : `.code-forge.yml does not load (${loaded.error}) — run \`code-forge validate\``);
378
+ cfg = loaded.config;
379
+ if (typeof flags.acceptance === 'string') {
380
+ acceptanceFile = path.resolve(cwd, flags.acceptance);
381
+ readAcceptance(acceptanceFile);
382
+ }
383
+ }
384
+ } catch (err) {
385
+ if (!(err instanceof UsageError)) throw err;
386
+ note(err.message);
387
+ return 2;
388
+ }
389
+ for (const s of skipped) note(`skipped ${s.file} (${s.reason})`);
390
+
391
+ if (files.length === 0) {
392
+ if (json) out(`${JSON.stringify({ ok: true, base: base.sha, base_from: base.from, run: null, block: null, files: [], skipped, totals: totals([]), nothing_to_review: true })}\n`);
393
+ else out('nothing to review\n');
394
+ return 0;
395
+ }
396
+
397
+ // ---- the run
398
+ const runId = typeof flags.run === 'string' ? flags.run : newRunId();
399
+ let reuse = false;
400
+ if (typeof flags.run === 'string') {
401
+ try {
402
+ const record = await readRun(runId);
403
+ if (record.status !== 'active') {
404
+ note(`run ${runId} has ended — give another --run or none`);
405
+ return 2;
406
+ }
407
+ reuse = true;
408
+ } catch (err) {
409
+ if (/** @type {any} */ (err)?.code !== 'no-run') throw err;
410
+ }
411
+ }
412
+ const blockId = `R-${new Date().toISOString().replace(/[-:]/g, '').replace('T', '-').slice(0, 15)}-${randomBytes(2).toString('hex')}`;
413
+ const tmpRootCfg = typeof cfg?.tmp?.root === 'string' ? path.resolve(repoRoot, cfg.tmp.root) : undefined;
414
+ const tmp = runRoot(`review-${runId}`, { root: tmpBase(tmpRootCfg) });
415
+ setRunRoot(tmp);
416
+
417
+ let started = false;
418
+ let blockOpen = false;
419
+ const keepRun = flags['keep-run'] === true;
420
+
421
+ // Signals. The FIRST SIGINT/SIGTERM kills the in-flight `code-forge` child (its process group,
422
+ // found in this command's pid registry, start time re-checked) and rejects the step's promise at
423
+ // once, so control reaches the `finally` cleanup without waiting for a `review-file --wait` to
424
+ // run out its `--max`. Once the cleanup has started (`inCleanup`), a first signal only counts:
425
+ // it kills nothing and aborts nothing, so the block stop and `run end` it would otherwise cut
426
+ // short complete. A SECOND signal stops waiting for that cleanup: a best-effort block stop,
427
+ // `run end` and worker reap (10 s each at most), then a hard exit.
428
+ const abort = new AbortController();
429
+ /** @type {Promise<never>} */
430
+ const aborted = new Promise((_, reject) => {
431
+ abort.signal.addEventListener('abort', () => reject(new Interrupted(String(abort.signal.reason))), { once: true });
432
+ });
433
+ aborted.catch(() => {});
434
+ let signals = 0;
435
+ let inCleanup = false;
436
+ const killInFlight = () => {
437
+ for (const entry of listEntries(pidsDir(tmp))) {
438
+ if (entry.start_time !== UNKNOWN_START_TIME && readStartTime(entry.pid) !== entry.start_time) continue;
439
+ try {
440
+ process.kill(-entry.pid, 'SIGTERM');
441
+ } catch {
442
+ try {
443
+ process.kill(entry.pid, 'SIGTERM');
444
+ } catch {
445
+ // already gone
446
+ }
447
+ }
448
+ }
449
+ };
450
+ const hardExit = async (/** @type {NodeJS.Signals} */ signal) => {
451
+ note(`second ${signal}: ending the run and exiting now`);
452
+ const quick = (/** @type {string[]} */ a) => Promise.resolve().then(() => execChild([process.execPath, BIN, ...a], { cwd: repoRoot, env: scrubbedEnv(), timeoutMs: 10_000 })).catch(() => null);
453
+ if (blockOpen) await quick(['block', 'stop', blockId, '--run', runId, '--reason', 'review only: interrupted']);
454
+ if (started && !keepRun) {
455
+ await quick(['run', 'end', '--run', runId]);
456
+ await reapWorkers(repoRoot, runId).catch(() => 0);
457
+ }
458
+ rmSync(tmp, { recursive: true, force: true });
459
+ process.exit(1);
460
+ };
461
+ const onSignal = (/** @type {NodeJS.Signals} */ signal) => {
462
+ signals += 1;
463
+ if (signals === 1) {
464
+ if (inCleanup) {
465
+ note(`${signal} during cleanup: finishing it (a second ${signal} exits at once)`);
466
+ return;
467
+ }
468
+ killInFlight();
469
+ abort.abort(signal);
470
+ } else if (signals === 2) {
471
+ void hardExit(signal);
472
+ }
473
+ };
474
+ process.on('SIGINT', onSignal);
475
+ process.on('SIGTERM', onSignal);
476
+
477
+ /** @param {string[]} cfArgs @param {number} [timeoutMs] */
478
+ const cf = async (cfArgs, timeoutMs = STEP_TIMEOUT_MS) => {
479
+ if (abort.signal.aborted) throw new Interrupted(String(abort.signal.reason));
480
+ const res = await Promise.race([execChild([process.execPath, BIN, ...cfArgs], { cwd: repoRoot, env: scrubbedEnv(), timeoutMs }), aborted]);
481
+ if (abort.signal.aborted) throw new Interrupted(String(abort.signal.reason));
482
+ return res;
483
+ };
484
+ /** @param {import('../util/exec.mjs').ExecResult} res @param {string} what */
485
+ const must = (res, what) => {
486
+ if (res.code !== 0) throw new Error(`${what} exited ${res.code ?? res.signal}: ${(res.stderr || res.stdout).trim().split('\n').at(-1) ?? ''}`);
487
+ return res;
488
+ };
489
+
490
+ let code = 1;
491
+ /** @type {FileOutcome[]} */
492
+ const outcomes = [];
493
+ try {
494
+ if (!acceptanceFile) {
495
+ const clause = typeof flags.intent === 'string' ? flags.intent : DEFAULT_CLAUSE;
496
+ acceptanceFile = path.join(tmp, 'acceptance.json');
497
+ writeFileSync(acceptanceFile, JSON.stringify([{ clause, tests: ['review-only'] }]), { mode: 0o600 });
498
+ }
499
+ note(`base ${base.sha.slice(0, 12)} (${base.from}) · ${files.length} file${files.length === 1 ? '' : 's'} · run ${runId}${reuse ? ' (reused)' : ''}`);
500
+ if (!reuse) {
501
+ started = true;
502
+ must(await cf(['run', 'start', '--cwd', repoRoot, '--run', runId]), 'run start');
503
+ }
504
+ blockOpen = true; // before the call: an interrupted `block open` may still have opened it (a stop of an unopened block only fails)
505
+ must(await cf(['block', 'open', blockId, '--run', runId, '--level', 'L2', '--base', base.sha, '--acceptance', acceptanceFile, '--owned', ...files]), 'block open');
506
+
507
+ /** @type {Array<{file: string, ticket: string | null, answer: any}>} */
508
+ const queued = [];
509
+ for (const file of files) {
510
+ const res = await cf(['review-file', file, '--block', blockId, '--run', runId]);
511
+ const answer = lastJSON(res.stdout);
512
+ queued.push({ file, ticket: typeof answer?.ticket === 'string' ? answer.ticket : null, answer });
513
+ }
514
+ for (const q of queued) {
515
+ if (q.ticket === null) {
516
+ outcomes.push(classify(q.file, { status: q.answer?.status ?? 'refused', reason: q.answer?.reason ?? q.answer?.status ?? 'not queued' }));
517
+ continue;
518
+ }
519
+ const res = await cf(['review-file', '--wait', q.ticket, '--max', `${maxS}s`], maxS * 1000 + 60_000);
520
+ outcomes.push(classify(q.file, lastJSON(res.stdout)));
521
+ }
522
+ const allOk = outcomes.every((o) => o.result === 'approved' || o.result === 'unchanged');
523
+ code = allOk ? 0 : 1;
524
+ if (json) out(`${JSON.stringify({ ok: allOk, base: base.sha, base_from: base.from, run: runId, block: blockId, files: outcomes, skipped, totals: totals(outcomes) })}\n`);
525
+ else out(renderText(outcomes));
526
+ } catch (err) {
527
+ note(err instanceof Interrupted ? `interrupted by ${err.message}; stopping the review` : `failed: ${err instanceof Error ? err.message : String(err)}`);
528
+ code = 1;
529
+ } finally {
530
+ // Every step below is independent: a step that fails or throws is reported and the next one
531
+ // still runs; the nested `finally` always detaches the handlers and removes the temp root. The
532
+ // exit code is the review's own whatever the cleanup does.
533
+ inCleanup = true;
534
+ try {
535
+ /**
536
+ * One cleanup child. `exec` forwards every signal it receives to its live children, so a
537
+ * first signal during the cleanup still cuts the running step short; a step that died of a
538
+ * signal (not of its timeout) is run once more, so the block is stopped and the run ends.
539
+ * @param {string[]} a
540
+ */
541
+ const cleanup = async (a) => {
542
+ const run = () => execChild([process.execPath, BIN, ...a], { cwd: repoRoot, env: scrubbedEnv(), timeoutMs: 60_000 });
543
+ const res = await run();
544
+ return res.code === null && res.signal !== null && !res.timedOut && signals > 0 ? run() : res;
545
+ };
546
+ if (blockOpen) {
547
+ try {
548
+ const stopped = await cleanup(['block', 'stop', blockId, '--run', runId, '--reason', 'review only: never closed, no proof']);
549
+ if (stopped.code !== 0) note(`block stop exited ${stopped.code ?? stopped.signal}`);
550
+ } catch (err) {
551
+ note(`block stop failed: ${messageOf(err)}`);
552
+ }
553
+ }
554
+ if (started && !keepRun) {
555
+ try {
556
+ const ended = await cleanup(['run', 'end', '--run', runId]);
557
+ if (ended.code !== 0) note(`run end exited ${ended.code ?? ended.signal}: ${ended.stderr.trim()}`);
558
+ } catch (err) {
559
+ note(`run end failed: ${messageOf(err)}`);
560
+ }
561
+ try {
562
+ await reapWorkers(repoRoot, runId);
563
+ } catch (err) {
564
+ note(`worker reap failed: ${messageOf(err)}`);
565
+ }
566
+ } else if (started) {
567
+ note(`run ${runId} kept (--keep-run): reuse it with --run ${runId}; end it with \`code-forge run end --run ${runId}\``);
568
+ }
569
+ } finally {
570
+ process.off('SIGINT', onSignal);
571
+ process.off('SIGTERM', onSignal);
572
+ setRunRoot(null);
573
+ try {
574
+ rmSync(tmp, { recursive: true, force: true });
575
+ } catch (err) {
576
+ note(`temp root not removed: ${messageOf(err)}`);
577
+ }
578
+ }
579
+ }
580
+ return code;
581
+ }
582
+
583
+ /**
584
+ * @param {string[]} args
585
+ * @returns {{flags: Record<string, any>}}
586
+ * @throws {StateError} `usage`
587
+ */
588
+ function parseReviewArgs(args) {
589
+ const { flags, positionals } = parseFlags(args, { values: ['base', 'acceptance', 'intent', 'run', 'max'], multi: ['files'], booleans: ['json', 'keep-run', 'help'] });
590
+ if (positionals.length > 0) throw new StateError('usage', `unexpected argument ${JSON.stringify(positionals[0])}`);
591
+ if (flags.acceptance !== undefined && flags.intent !== undefined) throw new StateError('usage', 'give --acceptance or --intent, not both');
592
+ if (typeof flags.intent === 'string' && flags.intent.trim().length === 0) throw new StateError('usage', '--intent needs text');
593
+ if (flags.max !== undefined && !/^[1-9]\d{0,5}s?$/.test(String(flags.max))) throw new StateError('usage', '--max must be whole seconds, like 900 or 900s');
594
+ if (typeof flags.run === 'string') assertRunIdUsage(flags.run);
595
+ return { flags };
596
+ }
597
+
598
+ /** @param {string} id */
599
+ function assertRunIdUsage(id) {
600
+ try {
601
+ assertRunId(id);
602
+ } catch {
603
+ throw new StateError('usage', '--run must match ^[a-z0-9][a-z0-9-]{0,63}$');
604
+ }
605
+ }
606
+
607
+ /** @param {string[]} args @returns {Promise<number>} */
608
+ export default async function review(args) {
609
+ return runReview(args);
610
+ }
@@ -21,6 +21,10 @@
21
21
  * else `findings` = the open fix list, `next` = what the orchestrator does (`fix` at `level`,
22
22
  * `trigger: review_stall | review_rounds`, `patch`), `stopped` = the stop reason.
23
23
  * Without `ctx.key` (a direct call) the hook is the engine alone and writes no approval row.
24
+ * Every packet (round 1, recheck, patch check) carries the block's acceptance clauses from the run
25
+ * record in its facts slot, labelled `Acceptance clauses` (`acceptanceExcerpt`): redacted, capped
26
+ * at an eighth of `review.budgets.full_in` with a truncation marker, and `(acceptance unavailable)`
27
+ * when the record has none — a missing acceptance never fails the review.
24
28
  * The first keyed ticket of a block (no `review.budget` row for it in the run yet) records the
25
29
  * block's review budget (§4.10, B19): the forecast over the block's changed owned files (tier from
26
30
  * the path floors, before S1), one `review.budget` row per block; a failure there never fails the
@@ -34,6 +38,7 @@ import { rmSync } from 'node:fs';
34
38
  import path from 'node:path';
35
39
  import { askJev } from '../decide/jev-client.mjs';
36
40
  import { planAndRecord, tierOf } from '../review/budget.mjs';
41
+ import { budgetFor } from '../review/packet.mjs';
37
42
  import { reviewFile, rulesRisk } from '../review/engine.mjs';
38
43
  import { computeFileSet, ownsFile } from '../gates/scope.mjs';
39
44
  import { newFileState, runRound } from '../review/fixloop.mjs';
@@ -84,14 +89,14 @@ import { assertTicketId } from './ticket.mjs';
84
89
  * `block-unknown`; a record that cannot be read or parsed throws `run-record-unreadable`, and a
85
90
  * block entry without a base `block-base-missing` — a guessed base could let a change escape review.
86
91
  * @param {string} runId @param {string} block
87
- * @returns {Promise<{base: string | null, level: string, owned: string[]}>}
92
+ * @returns {Promise<{base: string | null, level: string, owned: string[], acceptance?: unknown}>}
88
93
  */
89
94
  export async function blockEntryFor(runId, block) {
90
95
  let record;
91
96
  try {
92
97
  record = await readRun(runId);
93
98
  } catch (err) {
94
- if (/** @type {any} */ (err)?.code === 'no-run') return { base: null, level: 'L2', owned: [] };
99
+ if (/** @type {any} */ (err)?.code === 'no-run') return { base: null, level: 'L2', owned: [], acceptance: undefined };
95
100
  throw new Error('run-record-unreadable');
96
101
  }
97
102
  if (!record || typeof record !== 'object' || !record.blocks || typeof record.blocks !== 'object') throw new Error('run-record-unreadable');
@@ -100,7 +105,43 @@ export async function blockEntryFor(runId, block) {
100
105
  const sha = entry?.base_sha;
101
106
  if (typeof sha !== 'string' || sha.length === 0) throw new Error('block-base-missing');
102
107
  const owned = Array.isArray(entry.owned_files) ? entry.owned_files.filter((f) => typeof f === 'string') : [];
103
- return { base: sha, level: typeof entry.level === 'string' && /^L[0-3]$/.test(entry.level) ? entry.level : 'L2', owned };
108
+ return { base: sha, level: typeof entry.level === 'string' && /^L[0-3]$/.test(entry.level) ? entry.level : 'L2', owned, acceptance: entry.acceptance };
109
+ }
110
+
111
+ /** The acceptance line written when the block's clauses cannot be read. */
112
+ export const ACCEPTANCE_UNAVAILABLE = '(acceptance unavailable)';
113
+ const ACCEPTANCE_HEAD = 'Acceptance clauses (what the change must do):';
114
+
115
+ /**
116
+ * The block's acceptance clauses as the packet's facts-slot text: one `- <clause>` line each
117
+ * (whitespace folded, so a clause can never open a packet section), redacted, cut at an eighth of
118
+ * the `full_in` budget (bytes = tokens × 4) with a marker naming how many clauses were left out.
119
+ * Not a list of `{clause}` objects with text ⇒ `(acceptance unavailable)`.
120
+ * @param {unknown} acceptance - the run record's `blocks.<id>.acceptance`
121
+ * @param {Record<string, any> | undefined} cfg
122
+ * @returns {string}
123
+ */
124
+ export function acceptanceExcerpt(acceptance, cfg) {
125
+ const clauses = Array.isArray(acceptance)
126
+ ? acceptance.map((c) => (typeof c?.clause === 'string' ? /** @type {string} */ (redact(c.clause.replace(/\s+/g, ' ').trim())) : '')).filter((c) => c.length > 0)
127
+ : [];
128
+ if (clauses.length === 0) return `${ACCEPTANCE_HEAD}\n${ACCEPTANCE_UNAVAILABLE}`;
129
+ const maxBytes = Math.max(256, Math.floor((budgetFor(cfg, 'full_in') * 4) / 8));
130
+ const lines = [ACCEPTANCE_HEAD];
131
+ let used = Buffer.byteLength(ACCEPTANCE_HEAD) + 1;
132
+ for (let i = 0; i < clauses.length; i += 1) {
133
+ const line = `- ${clauses[i]}`;
134
+ const size = Buffer.byteLength(line) + 1;
135
+ if (used + size > maxBytes) {
136
+ if (lines.length === 1) lines.push(`${Buffer.from(line).subarray(0, Math.max(0, maxBytes - used - 4)).toString('utf8')} …`);
137
+ const left = clauses.length - (lines.length - 1);
138
+ if (left > 0) lines.push(`(acceptance truncated: ${left} more clause(s) over the packet budget)`);
139
+ break;
140
+ }
141
+ lines.push(line);
142
+ used += size;
143
+ }
144
+ return lines.join('\n');
104
145
  }
105
146
 
106
147
  /**
@@ -151,11 +192,11 @@ export async function reviewTicket(ticket, ctx) {
151
192
  try {
152
193
  if (!ctx.key) {
153
194
  return await reviewFile(
154
- { repoRoot: ctx.repoRoot, file: ticket.file, base: entry.base, cfg: ctx.cfg, workDir },
195
+ { repoRoot: ctx.repoRoot, file: ticket.file, base: entry.base, cfg: ctx.cfg, workDir, factsExcerpt: acceptanceExcerpt(entry.acceptance, ctx.cfg) },
155
196
  { spawn: ctx.spawn, ...(ctx.writeRow ? { writeRow: ctx.writeRow } : {}) },
156
197
  );
157
198
  }
158
- return await fixLoopRound(ticket, ctx, { base: entry.base, level: entry.level, owned: entry.owned, key: ctx.key, workDir });
199
+ return await fixLoopRound(ticket, ctx, { base: entry.base, level: entry.level, owned: entry.owned, key: ctx.key, workDir, factsExcerpt: acceptanceExcerpt(entry.acceptance, ctx.cfg) });
159
200
  } finally {
160
201
  rmSync(workDir, { recursive: true, force: true });
161
202
  }
@@ -164,10 +205,10 @@ export async function reviewTicket(ticket, ctx) {
164
205
  /**
165
206
  * One fix-loop round for the ticket (see the module doc).
166
207
  * @param {import('./queue.mjs').Ticket} ticket @param {ReviewContext} ctx
167
- * @param {{base: string | null, level: string, owned: string[], key: Buffer, workDir: string}} opts
208
+ * @param {{base: string | null, level: string, owned: string[], key: Buffer, workDir: string, factsExcerpt: string}} opts
168
209
  * @returns {Promise<ReviewOutcome>}
169
210
  */
170
- async function fixLoopRound(ticket, ctx, { base, level, owned, key, workDir }) {
211
+ async function fixLoopRound(ticket, ctx, { base, level, owned, key, workDir, factsExcerpt }) {
171
212
  if (!ctx.readRows || !ctx.writeRow) return { status: 'unavailable', reason: 'no-ledger', approved: false, engine: 'adaptive', sessions: [] };
172
213
  let rows;
173
214
  try {
@@ -232,9 +273,10 @@ async function fixLoopRound(ticket, ctx, { base, level, owned, key, workDir }) {
232
273
  workDir,
233
274
  base,
234
275
  writeRow,
276
+ factsExcerpt,
235
277
  ...(jev ? { jev } : {}),
236
278
  review: async () => {
237
- engineOutcome = await reviewFile({ repoRoot: ctx.repoRoot, file: ticket.file, base, cfg: ctx.cfg, workDir }, { spawn: ctx.spawn, writeRow });
279
+ engineOutcome = await reviewFile({ repoRoot: ctx.repoRoot, file: ticket.file, base, cfg: ctx.cfg, workDir, factsExcerpt }, { spawn: ctx.spawn, writeRow });
238
280
  return engineOutcome;
239
281
  },
240
282
  spawn: async (opts) => {