@codedology/code-forge 0.7.6 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (63) hide show
  1. package/CHANGELOG.md +51 -0
  2. package/README.md +57 -2
  3. package/docs/README.md +2 -1
  4. package/docs/reference/blocks.json +171 -1
  5. package/docs/reference/config.md +26 -8
  6. package/package.json +1 -1
  7. package/schema/code-forge.schema.json +81 -4
  8. package/skill/SKILL.md +6 -6
  9. package/skill/references/code.md +33 -4
  10. package/skill/references/ledger.md +8 -4
  11. package/skill/references/plan.md +12 -0
  12. package/skill/references/proof.md +44 -3
  13. package/skill/references/review.md +4 -2
  14. package/skill/references/security.md +1 -1
  15. package/skill/scripts/forge +1 -1
  16. package/skill/templates/coder-brief.md +18 -1
  17. package/src/autopilot/binnacle.mjs +21 -3
  18. package/src/cli/block.mjs +337 -37
  19. package/src/cli/context.mjs +175 -0
  20. package/src/cli/gates.mjs +125 -15
  21. package/src/cli/ledger.mjs +46 -2
  22. package/src/cli/proof.mjs +62 -16
  23. package/src/cli/report.mjs +21 -4
  24. package/src/cli/review-file.mjs +61 -6
  25. package/src/cli/run.mjs +16 -6
  26. package/src/cli/status.mjs +212 -0
  27. package/src/config/validate.mjs +45 -11
  28. package/src/context/pack.mjs +319 -0
  29. package/src/decide/lane-facts.mjs +41 -0
  30. package/src/doctor/local.mjs +2 -1
  31. package/src/engines/sentinel.mjs +27 -6
  32. package/src/gates/detect.mjs +21 -8
  33. package/src/gates/expand.mjs +202 -0
  34. package/src/gates/owned-count.mjs +66 -0
  35. package/src/gates/run.mjs +49 -12
  36. package/src/install/wizard/answers.mjs +34 -5
  37. package/src/install/wizard/flags.mjs +8 -1
  38. package/src/install/wizard/project-file.mjs +2 -0
  39. package/src/install/wizard/run.mjs +6 -0
  40. package/src/install/wizard/steps.mjs +24 -4
  41. package/src/install/wizard/summary.mjs +13 -5
  42. package/src/ledger/export.mjs +1 -1
  43. package/src/ledger/outside-review.mjs +164 -0
  44. package/src/ledger/report.mjs +32 -6
  45. package/src/proof/export.mjs +93 -10
  46. package/src/proof/lock.mjs +28 -5
  47. package/src/proof/worktree.mjs +482 -0
  48. package/src/review/engine.mjs +3 -2
  49. package/src/review/gate-check.mjs +9 -5
  50. package/src/review/open-findings.mjs +59 -0
  51. package/src/state/block-end.mjs +290 -0
  52. package/src/state/block.mjs +111 -37
  53. package/src/state/brief-status.mjs +131 -0
  54. package/src/state/config-snapshot.mjs +68 -8
  55. package/src/state/registry.mjs +35 -4
  56. package/src/state/run.mjs +21 -2
  57. package/src/util/breadcrumbs.mjs +1 -0
  58. package/src/util/forbidden.mjs +36 -1
  59. package/src/util/gate-token.mjs +8 -0
  60. package/src/worker/engine.mjs +157 -17
  61. package/src/worker/loop.mjs +26 -7
  62. package/src/worker/queue.mjs +36 -10
  63. package/src/worker/ticket.mjs +46 -6
@@ -0,0 +1,175 @@
1
+ /**
2
+ * `code-forge context pack --run <id> --block <id> [--json]` (block B63a; issue #13 item 5, issue
3
+ * #10 item 2). Builds the coder context pack of one block from the `context` key of the project's
4
+ * LIVE `.code-forge.yml` in the main tree (not the run-start snapshot: `context` is not authority)
5
+ * — see `src/context/pack.mjs` for the sections, the order and the line cap — and writes it to
6
+ * `<run root>/context/<block>.md`. The run root comes from the run's own config snapshot
7
+ * (`tmp.root` is fixed for the run), as for the worker. The orchestrator sends the pack's text with
8
+ * every coder task.
9
+ *
10
+ * Prints two lines: the path, then `tokens ~<n>` (the review packets' bytes / 4 estimate); with
11
+ * `--json` one line `{"path":…,"tokens":…,"lines":…,"cut":…,"sha256":…}`. Each pack written
12
+ * appends a signed `context.pack {block, path, sha256, tokens, lines, cut}` ledger row — unless
13
+ * the block's last verified `context.pack` row has the same sha256 and path (same pack: no row,
14
+ * same output). The ledger is read before anything is written. The new text goes to a temp file
15
+ * first; the signed row is appended while it is still there, and only then is it renamed over
16
+ * `<block>.md`. A row that cannot be appended removes only the temp file, so an earlier pack stays
17
+ * as it was and nothing was written. A rename that fails after the row was appended says so.
18
+ *
19
+ * Exit codes: 0 written, 1 refused (no `context` key, a rules or lessons file missing or outside
20
+ * the repository, a bad `context` value, a control character, an unknown run or block, a file or
21
+ * ledger write that fails), 2 usage. Never runs any command it lists.
22
+ */
23
+
24
+ import { createHash } from 'node:crypto';
25
+ import { mkdirSync, renameSync, rmSync, writeFileSync } from 'node:fs';
26
+ import path from 'node:path';
27
+ import { loadProjectConfig } from '../config/load.mjs';
28
+ import { assemblePack, ContextError, contextSettings, packTokens, readSources } from '../context/pack.mjs';
29
+ import { appendRow, readAllRows } from '../ledger/write.mjs';
30
+ import { runRootFor } from '../session/spawn.mjs';
31
+ import { parseFlags } from '../state/cli-args.mjs';
32
+ import { configInForce } from '../state/config-snapshot.mjs';
33
+ import { StateError } from '../state/paths.mjs';
34
+ import { readRun, writeSigned } from '../state/run.mjs';
35
+ import { loadKey, verifyRow } from '../state/signer.mjs';
36
+ import { assertBlockId, mainRootOf, WorkerError } from '../worker/ticket.mjs';
37
+ import { writeSafe } from '../util/redact.mjs';
38
+
39
+ const USAGE = 'usage: code-forge context pack --run <id> --block <id> [--json]\n';
40
+
41
+ /** @param {unknown} err @returns {string} the fs error's code (or message) for a one-line refusal. */
42
+ const fsCode = (err) => /** @type {NodeJS.ErrnoException} */ (err)?.code ?? (err instanceof Error ? err.message : 'error');
43
+
44
+ /**
45
+ * @typedef {object} ContextDeps
46
+ * @property {{write: (s: string) => unknown}} [stdout]
47
+ * @property {{write: (s: string) => unknown}} [stderr]
48
+ * @property {(row: Record<string, any>) => Promise<unknown>} [appendRow] - the ledger append
49
+ * (default: the project's ledger file).
50
+ */
51
+
52
+ /**
53
+ * The last verified `context.pack` row of this run and block, or null.
54
+ * @param {string} runId @param {string} slug @param {string} block
55
+ * @returns {Promise<Record<string, any> | null>}
56
+ */
57
+ async function lastPackRow(runId, slug, block) {
58
+ const rows = (await readAllRows(slug)).filter((r) => r?.event === 'context.pack' && r.run === runId && r.block === block);
59
+ if (rows.length === 0) return null;
60
+ const key = await loadKey(runId);
61
+ const valid = rows.filter((r) => verifyRow(r, key).ok);
62
+ return valid.length > 0 ? valid[valid.length - 1] : null;
63
+ }
64
+
65
+ /**
66
+ * @param {string[]} args @param {ContextDeps} [deps]
67
+ * @returns {Promise<number>}
68
+ */
69
+ export async function runContext(args, deps = {}) {
70
+ const stdout = deps.stdout ?? process.stdout;
71
+ const stderr = deps.stderr ?? process.stderr;
72
+ const [sub, ...rest] = args;
73
+ if (sub !== 'pack') {
74
+ writeSafe(stderr, USAGE);
75
+ return 2;
76
+ }
77
+ try {
78
+ const { flags, positionals } = parseFlags(rest, { values: ['run', 'block'], booleans: ['json'] });
79
+ if (positionals.length > 0 || typeof flags.run !== 'string' || typeof flags.block !== 'string') throw new StateError('usage', 'context pack needs --run and --block');
80
+ const runId = flags.run;
81
+ let block;
82
+ try {
83
+ block = assertBlockId(flags.block);
84
+ } catch {
85
+ throw new StateError('usage', `--block: ${JSON.stringify(flags.block)} is not a block id`);
86
+ }
87
+ const record = await readRun(runId);
88
+ const entry = record.blocks?.[block];
89
+ if (!entry) throw new ContextError('no-block', `run ${runId} has no block ${block}`);
90
+ // rules and lessons come from the MAIN tree, never from a block worktree; so does the config
91
+ const repoRoot = await mainRootOf(record.workspace);
92
+ // only `context` is read LIVE (it is not authority)
93
+ const loaded = await loadProjectConfig(repoRoot);
94
+ if (!loaded.ok) throw new ContextError('bad-config', loaded.message);
95
+ const live = /** @type {Record<string, any>} */ (loaded.config);
96
+ const settings = contextSettings(live);
97
+ if (settings === null) throw new ContextError('no-context', 'the config has no context key: add context (rules, lessons, commands, max_lines) to .code-forge.yml');
98
+ const sources = readSources(repoRoot, settings);
99
+ const { text, lines, cut } = assemblePack({
100
+ settings,
101
+ sources,
102
+ block: {
103
+ run: runId,
104
+ block,
105
+ level: entry.level ?? null,
106
+ owned: Array.isArray(entry.owned_files) ? entry.owned_files : [],
107
+ tests: Array.isArray(entry.test_files) ? entry.test_files : null,
108
+ worktree: entry.worktree && typeof entry.worktree.path === 'string' ? { path: entry.worktree.path, branch: entry.worktree.branch } : null,
109
+ },
110
+ });
111
+ const sha256 = createHash('sha256').update(text).digest('hex');
112
+ const tokens = packTokens(text);
113
+ // the run root from the run's own snapshot (`tmp.root` is fixed for the run, as the worker
114
+ // made it); a record from before snapshots has none, and the live value is all there is
115
+ const snapshot = configInForce(record) ?? live;
116
+ const tmpRoot = typeof snapshot?.tmp?.root === 'string' ? snapshot.tmp.root : undefined;
117
+ let runRoot;
118
+ try {
119
+ runRoot = runRootFor(runId, tmpRoot);
120
+ } catch (err) {
121
+ throw new ContextError('no-run-root', `the run root cannot be used (${err instanceof Error ? err.message : 'error'})`);
122
+ }
123
+ const dir = path.join(runRoot, 'context');
124
+ const file = path.join(dir, `${block}.md`);
125
+ // every ledger read and check happens BEFORE the file is touched: a refusal here writes nothing
126
+ const last = await lastPackRow(runId, record.project, block);
127
+ const needsRow = last === null || last.sha256 !== sha256 || last.path !== file;
128
+ const tmp = `${file}.${process.pid}.tmp`;
129
+ try {
130
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
131
+ writeFileSync(tmp, text, { mode: 0o600 });
132
+ } catch (err) {
133
+ rmSync(tmp, { force: true });
134
+ throw new ContextError('write-failed', `cannot write ${file} (${fsCode(err)})`);
135
+ }
136
+ // the row first, while the new text is only in the temp file: a failed append leaves the
137
+ // earlier pack (if any) exactly as it was
138
+ if (needsRow) {
139
+ const append = deps.appendRow ?? ((/** @type {Record<string, any>} */ row) => appendRow(row, { slug: record.project }));
140
+ try {
141
+ await writeSigned(runId, append, { event: 'context.pack', block, path: file, sha256, tokens, lines, cut });
142
+ } catch (err) {
143
+ rmSync(tmp, { force: true });
144
+ writeSafe(stderr, `context: the context.pack ledger row could not be written (${err instanceof Error ? err.message : 'error'}); nothing was written\n`);
145
+ return 1;
146
+ }
147
+ }
148
+ try {
149
+ renameSync(tmp, file);
150
+ } catch (err) {
151
+ rmSync(tmp, { force: true });
152
+ const after = needsRow ? 'its context.pack ledger row was already written' : 'nothing was written';
153
+ writeSafe(stderr, `context: cannot write ${file} (${fsCode(err)}); ${after}\n`);
154
+ return 1;
155
+ }
156
+ if (flags.json === true) writeSafe(stdout, `${JSON.stringify({ path: file, tokens, lines, cut, sha256 })}\n`);
157
+ else writeSafe(stdout, `${file}\ntokens ~${tokens}\n`);
158
+ return 0;
159
+ } catch (thrown) {
160
+ if (thrown instanceof StateError && thrown.code === 'usage') {
161
+ writeSafe(stderr, `context: ${thrown.message}\n${USAGE}`);
162
+ return 2;
163
+ }
164
+ if (thrown instanceof ContextError || thrown instanceof StateError || thrown instanceof WorkerError) {
165
+ writeSafe(stderr, `context: ${thrown.message}; nothing was written\n`);
166
+ return 1;
167
+ }
168
+ throw thrown;
169
+ }
170
+ }
171
+
172
+ /** @param {string[]} args @returns {Promise<number>} */
173
+ export default async function context(args) {
174
+ return runContext(args);
175
+ }
package/src/cli/gates.mjs CHANGED
@@ -7,7 +7,24 @@
7
7
  * gates detect --cwd <dir>
8
8
  * gates run --cwd <dir> [--slug <slug>] [--timeout-ms <n>] [--run <r> --block <id>]
9
9
  * (with --run/--block the gates run behind the proof lock, `runGatesGuarded`: another block
10
- * holding the lock ⇒ `{"result":"proof.busy","holder":…}` and exit 1)
10
+ * holding the lock ⇒ `{"result":"proof.busy","holder":…}` and exit 1. B60: a gate argv item
11
+ * that is exactly `{test_files}` runs the block's related tests, or is dropped for the full
12
+ * suite — over `gates.full_suite_threshold_files` owned files (globs and directories count
13
+ * the tracked and new (not ignored) files they cover), no test file known, or no --run/--block (`reason: no_tests`);
14
+ * that gate's result and `gate.red` row, and the `gate.done` row, carry `scope: related|full`
15
+ * with `test_count` or `reason: threshold|no_tests`, and with --run/--block the rows name `run`
16
+ * and `block`. A gate given as `{argv, env}` runs with those variables, shown by name only
17
+ * (`env_keys`). code-forge never prints or records an env value. Gate env is not a secret
18
+ * store. Child output is masked only for values of 8 or more characters.
19
+ * B62b: for a block with a worktree (`proof.isolation: worktree`) the gates run in that tree —
20
+ * at the workspace's place in it (`blockTree`) — and `{test_files}` and the threshold count
21
+ * the files there. The gates are detected in the tree and configured by the main workspace's
22
+ * `.code-forge.yml`. `--cwd` may be left out, or must name the workspace's place in the tree:
23
+ * the tree's top level when the workspace is the repository's top level, else the same
24
+ * subdirectory of the tree (then the tree's top level is refused too). Any other `--cwd`, and
25
+ * any `--cwd=<dir>` argument, is refused (exit 2, nothing run). A tree that is gone, malformed or off its branch is refused
26
+ * with exit 1 (the message on stderr, no gate run, no row). The JSON output and the `gate.red` and
27
+ * `gate.done` rows then carry `worktree: <tree path>`.)
11
28
  * gates secret-scan --cwd <dir> --file <path> [--file <path> ...]
12
29
  * gates safe-edit --cwd <dir> --base <sha>
13
30
  * gates scope --cwd <dir> --base <sha>
@@ -15,31 +32,39 @@
15
32
  * gates transcript-grep --file <path>
16
33
  */
17
34
 
35
+ import { realpathSync } from 'node:fs';
18
36
  import { readFile } from 'node:fs/promises';
37
+ import path from 'node:path';
19
38
  import { writeSafe, redactJSON } from '../util/redact.mjs';
20
39
  import { appendRow } from '../ledger/write.mjs';
21
40
  import { loadProjectConfig } from '../config/load.mjs';
22
41
  import { detectGates } from '../gates/detect.mjs';
42
+ import { printableGates, thresholdOf } from '../gates/expand.mjs';
23
43
  import { runGates } from '../gates/run.mjs';
24
44
  import { runGatesGuarded } from '../proof/lock.mjs';
45
+ import { blockTree } from '../proof/worktree.mjs';
46
+ import { readRun } from '../state/run.mjs';
47
+ import { StateError } from '../state/paths.mjs';
25
48
  import { scanFiles } from '../gates/secret-scan.mjs';
26
49
  import { checkSafeEdit } from '../gates/safe-edit.mjs';
27
50
  import { computeFileSet } from '../gates/scope.mjs';
28
51
  import { checkAcceptance, parseTapPassed } from '../gates/acceptance.mjs';
29
52
  import { checkTranscript } from '../gates/transcript-grep.mjs';
30
53
 
54
+ /** B60: the gate env rule, in the same words as the schema and proof.md. */
55
+ const GATE_ENV_RULE = 'code-forge never prints or records an env value. Gate env is not a secret store. Child output is masked only for values of 8 or more characters.';
56
+
31
57
  /**
32
58
  * Auto-detected gates (`detectGates`), overridden per-key by an on-disk `.code-forge.yml`'s
33
59
  * `gates.{test,lint,types,format}` when that key is explicitly present — a project that already
34
60
  * confirmed/edited its gates at `init` step 7 (plan §2 step 7) is authoritative over the table.
35
61
  * A missing/unparseable config file is not an error here: detection alone is a valid answer.
36
62
  * @param {string} cwd
37
- * @returns {Promise<import('../gates/detect.mjs').DetectedGates>}
63
+ * @param {Record<string, any> | null} configured - the config's `gates` section ({@link configuredGates}).
64
+ * @returns {import('../gates/detect.mjs').DetectedGates}
38
65
  */
39
- async function resolveGates(cwd) {
66
+ function resolveGates(cwd, configured) {
40
67
  const detected = detectGates(cwd);
41
- const loaded = await loadProjectConfig(cwd);
42
- const configured = loaded.ok && loaded.config?.gates && typeof loaded.config.gates === 'object' ? loaded.config.gates : null;
43
68
  if (!configured) return detected;
44
69
  return {
45
70
  stack: detected.stack,
@@ -50,6 +75,16 @@ async function resolveGates(cwd) {
50
75
  };
51
76
  }
52
77
 
78
+ /**
79
+ * The on-disk `.code-forge.yml`'s `gates` section, or null when there is none (or it does not load).
80
+ * @param {string} cwd
81
+ * @returns {Promise<Record<string, any> | null>}
82
+ */
83
+ async function configuredGates(cwd) {
84
+ const loaded = await loadProjectConfig(cwd);
85
+ return loaded.ok && loaded.config?.gates && typeof loaded.config.gates === 'object' ? loaded.config.gates : null;
86
+ }
87
+
53
88
  /**
54
89
  * `production.markers`/`production.names` from an on-disk config, merged into the forbidden list
55
90
  * `transcript-grep` scans with — absent/unparseable config yields no extra tokens.
@@ -91,7 +126,8 @@ function flagValue(args, flag) {
91
126
  * go through `flagValue(...) ?? process.cwd()`, which cannot tell "absent" from "malformed" apart
92
127
  * and silently ran gates in the wrong directory for the latter).
93
128
  * @param {string[]} args @param {(s: string) => void} err @param {string} label
94
- * @returns {{ok: true, value: string} | {ok: false}}
129
+ * `given` says whether `--cwd` was on the command line (`value` is then its value, not the default).
130
+ * @returns {{ok: true, value: string, given: boolean} | {ok: false}}
95
131
  */
96
132
  function resolveCwd(args, err, label) {
97
133
  const flag = readOptionalFlag(args, '--cwd');
@@ -99,7 +135,7 @@ function resolveCwd(args, err, label) {
99
135
  err(`${label}: --cwd requires a value\n`);
100
136
  return { ok: false };
101
137
  }
102
- return { ok: true, value: flag.value ?? process.cwd() };
138
+ return { ok: true, value: flag.value ?? process.cwd(), given: flag.value !== undefined };
103
139
  }
104
140
 
105
141
  /** @param {string[]} args @param {string} flag @returns {string[]} every value of a repeatable flag. */
@@ -111,6 +147,51 @@ function repeatedFlagValues(args, flag) {
111
147
  return values;
112
148
  }
113
149
 
150
+ /**
151
+ * B62b: the worktree the block `blockId` of run `runId` works in, with the run's main workspace;
152
+ * null for a block without one. A run or block that cannot be read is null too: the proof lock
153
+ * (`runGatesGuarded`) refuses it with its own message.
154
+ * @param {string} runId @param {string} blockId
155
+ * @returns {Promise<{tree: import('../proof/worktree.mjs').BlockTree, workspace: string} | null>}
156
+ * @throws {import('../state/paths.mjs').StateError} a `worktree-*` code when the recorded tree is
157
+ * malformed, gone or unusable; the message names the block and run and says no gate was run.
158
+ */
159
+ async function blockWorktree(runId, blockId) {
160
+ let record;
161
+ try {
162
+ record = await readRun(runId);
163
+ } catch {
164
+ return null;
165
+ }
166
+ let tree;
167
+ try {
168
+ tree = await blockTree(record.workspace, record.blocks?.[blockId]);
169
+ } catch (thrown) {
170
+ if (thrown instanceof StateError) throw new StateError(thrown.code, `block ${blockId} of run ${runId}: ${thrown.message}; no gate was run`);
171
+ throw thrown;
172
+ }
173
+ return tree ? { tree, workspace: record.workspace } : null;
174
+ }
175
+
176
+ /** @param {string} p @returns {string} the realpath of `p`, or `p` resolved when it does not exist. */
177
+ function realOrResolved(p) {
178
+ try {
179
+ return realpathSync(p);
180
+ } catch {
181
+ return path.resolve(p);
182
+ }
183
+ }
184
+
185
+ /**
186
+ * B60: the scope fields of a `{test_files}` gate's result (`scope` with `test_count` or `reason`),
187
+ * or none for any other gate.
188
+ * @param {Record<string, any>} result @returns {Record<string, any>}
189
+ */
190
+ function scopeOf({ scope, test_count: testCount, reason }) {
191
+ if (scope === undefined) return {};
192
+ return scope === 'related' ? { scope, test_count: testCount } : { scope, reason };
193
+ }
194
+
114
195
  /**
115
196
  * @param {string[]} args
116
197
  * @param {{stdout?: {write: (s: string) => unknown}, stderr?: {write: (s: string) => unknown}}} [deps]
@@ -121,7 +202,11 @@ export async function runGatesVerb(args, deps = {}) {
121
202
  const out = (/** @type {object} */ value) => writeSafe(stdout, `${redactJSON(value)}\n`);
122
203
  const err = (/** @type {string} */ s) => writeSafe(stderr, s);
123
204
  const usage = () =>
124
- err('usage: code-forge gates detect|run|secret-scan|safe-edit|scope|acceptance|transcript-grep --cwd <dir> [...]\n');
205
+ err(
206
+ 'usage: code-forge gates detect|run|secret-scan|safe-edit|scope|acceptance|transcript-grep --cwd <dir> [...]\n' +
207
+ ` gates run: an argv item exactly {test_files} runs the block's related tests (needs --run and --block); over gates.full_suite_threshold_files owned files, or with no test file known, the full suite runs.\n` +
208
+ ` gates run: a gate may be {argv, env}. ${GATE_ENV_RULE}\n`,
209
+ );
125
210
 
126
211
  const [sub] = args;
127
212
 
@@ -129,14 +214,16 @@ export async function runGatesVerb(args, deps = {}) {
129
214
  if (sub === 'detect') {
130
215
  const cwdArg = resolveCwd(args, err, 'gates detect');
131
216
  if (!cwdArg.ok) return 2;
132
- out(await resolveGates(cwdArg.value));
217
+ const gates = resolveGates(cwdArg.value, await configuredGates(cwdArg.value));
218
+ // B60: a gate given with env is printed with its variable NAMES (`env_keys`), never the values
219
+ out(printableGates(gates));
133
220
  return 0;
134
221
  }
135
222
 
136
223
  if (sub === 'run') {
137
224
  const cwdArg = resolveCwd(args, err, 'gates run');
138
225
  if (!cwdArg.ok) return 2;
139
- const cwd = cwdArg.value;
226
+ let cwd = cwdArg.value;
140
227
 
141
228
  const timeoutFlag = readOptionalFlag(args, '--timeout-ms');
142
229
  if (!timeoutFlag.ok) {
@@ -165,10 +252,28 @@ export async function runGatesVerb(args, deps = {}) {
165
252
  return 2;
166
253
  }
167
254
 
168
- const gates = await resolveGates(cwd);
255
+ // B62b: a block with a worktree runs its gates in that tree, configured by the main workspace
256
+ const placed = runFlag.value !== undefined && blockFlag.value !== undefined ? await blockWorktree(runFlag.value, blockFlag.value) : null;
257
+ if (placed) {
258
+ // `--cwd=<dir>` is no form of --cwd: for a worktree block it is refused, never ignored
259
+ const joined = args.find((a) => a.startsWith('--cwd='));
260
+ if (joined !== undefined) {
261
+ err(`gates run: block ${blockFlag.value} of run ${runFlag.value} works in its worktree ${placed.tree.workspace}, not in ${joined} — drop it (--cwd=<dir> is not read as --cwd); no gate was run\n`);
262
+ return 2;
263
+ }
264
+ if (cwdArg.given && realOrResolved(cwd) !== realOrResolved(placed.tree.workspace)) {
265
+ err(`gates run: block ${blockFlag.value} of run ${runFlag.value} works in its worktree ${placed.tree.workspace}, not in --cwd ${cwd} — drop --cwd (or give the worktree); no gate was run\n`);
266
+ return 2;
267
+ }
268
+ cwd = placed.tree.workspace;
269
+ }
270
+ const configured = await configuredGates(placed ? placed.workspace : cwd); // loaded once: the gates and the threshold
271
+ const gates = resolveGates(cwd, configured);
272
+ const inTree = placed ? { worktree: placed.tree.path } : {};
169
273
  let ran;
170
274
  if (runFlag.value !== undefined && blockFlag.value !== undefined) {
171
- const guarded = await runGatesGuarded({ runId: runFlag.value, blockId: blockFlag.value, gates, cwd, ...(timeoutMs ? { timeoutMs } : {}) });
275
+ const threshold = thresholdOf(configured?.full_suite_threshold_files);
276
+ const guarded = await runGatesGuarded({ runId: runFlag.value, blockId: blockFlag.value, gates, cwd, threshold, ...(timeoutMs ? { timeoutMs } : {}) });
172
277
  if (guarded.result === 'proof.busy') {
173
278
  out({ result: 'proof.busy', holder: /** @type {{holder: string}} */ (guarded).holder });
174
279
  return 1;
@@ -180,14 +285,19 @@ export async function runGatesVerb(args, deps = {}) {
180
285
  const { results, allOk } = ran;
181
286
 
182
287
  if (slug) {
288
+ // B60: with --run/--block the rows name the run and the block, so the run's binnacle reads them
289
+ const owner = runFlag.value !== undefined ? { run: runFlag.value, block: blockFlag.value, ...inTree } : {};
183
290
  for (const result of results) {
184
291
  if (!result.skipped && !result.ok) {
185
- await appendRow({ event: 'gate.red', gate: result.gate, code: result.code }, { slug });
292
+ await appendRow({ ...owner, event: 'gate.red', gate: result.gate, code: result.code, ...scopeOf(result) }, { slug });
186
293
  }
187
294
  }
188
- await appendRow({ event: 'gate.done', stack: gates.stack, all_ok: allOk }, { slug });
295
+ // B60: one scope per run (every `{test_files}` gate shares it), named on gate.done too;
296
+ // without --run/--block that is `scope: full, reason: no_tests`
297
+ const scoped = results.find((r) => r.scope !== undefined);
298
+ await appendRow({ ...owner, event: 'gate.done', stack: gates.stack, all_ok: allOk, ...(scoped ? scopeOf(scoped) : {}) }, { slug });
189
299
  }
190
- out({ stack: gates.stack, results, allOk });
300
+ out({ stack: gates.stack, ...inTree, results, allOk });
191
301
  return allOk ? 0 : 1;
192
302
  }
193
303
 
@@ -8,18 +8,26 @@
8
8
  * ledger outcome --pr <n> --ci red|green|reverted --slug <slug>
9
9
  * ledger tail --slug <slug> [--n <count> | -n <count>]
10
10
  * ledger add coder --run <r> --block <b> --usd <n> [--note "..."]
11
+ * ledger outside-review --run <r> --pr <n> --rounds <n> --findings <n> [--link <url>] [--block <id>]...
11
12
  *
12
13
  * B33: `ledger add coder` records manual/cloud coder spend (a session code-forge never saw) as a
13
14
  * signed `session` row (`role: coder, manual: true, usd, cost_source: manual`) in the run's
14
15
  * ledger, so `report` and the `budget.usd` check count it. The slug is the run record's project.
16
+ *
17
+ * B66: `ledger outside-review` records the rounds and findings of an outside review of one PR as a
18
+ * signed `outside_review` row in the run's project ledger (the `report` section `outside_review`
19
+ * puts it next to the code-forge cost of the PR's blocks). An orchestrator/human verb, never a
20
+ * coder one. A later call for the same PR replaces the earlier row in every reader.
15
21
  */
16
22
 
17
- import { writeSafe } from '../util/redact.mjs';
18
23
  import { buildCalibration } from '../ledger/calibration.mjs';
19
24
  import { recordCiOutcome, reviewedEntriesFromRows, scanGitAndRecord } from '../ledger/outcome.mjs';
25
+ import { OUTSIDE_REVIEW_EVENT, OUTSIDE_REVIEW_USAGE, outsideReviewRecordedLine, parseOutsideReviewArgs } from '../ledger/outside-review.mjs';
20
26
  import { roundUsd, runSpend } from '../ledger/spend.mjs';
21
27
  import { appendRow, readAllRows } from '../ledger/write.mjs';
22
28
  import { readRun, writeSigned } from '../state/run.mjs';
29
+ import { loadKey } from '../state/signer.mjs';
30
+ import { redact, writeSafe } from '../util/redact.mjs';
23
31
 
24
32
  /**
25
33
  * @param {string[]} args @param {string} flag
@@ -63,10 +71,11 @@ export async function runLedger(args, deps = {}) {
63
71
  const { stdout = process.stdout, stderr = process.stderr } = deps;
64
72
  const out = (/** @type {string} */ s) => writeSafe(stdout, s);
65
73
  const err = (/** @type {string} */ s) => writeSafe(stderr, s);
66
- const usage = () => err('usage: code-forge ledger calibration|outcome|tail --slug <slug> [...] (tail: [--n|-n <count>]) | add coder --run <r> --block <b> --usd <n> [--note "..."]\n');
74
+ const usage = () => err('usage: code-forge ledger calibration|outcome|tail --slug <slug> [...] (tail: [--n|-n <count>]) | add coder --run <r> --block <b> --usd <n> [--note "..."] | outside-review --run <r> --pr <n> --rounds <n> --findings <n> [--link <url>] [--block <id>]...\n');
67
75
 
68
76
  const [sub] = args;
69
77
  if (sub === 'add') return addSpend(args.slice(1), { out, err });
78
+ if (sub === 'outside-review') return addOutsideReview(args.slice(1), { out, err });
70
79
  const slug = flagValue(args, '--slug');
71
80
  if (!slug) {
72
81
  usage();
@@ -245,6 +254,41 @@ async function addSpend(args, { out, err }) {
245
254
  }
246
255
  }
247
256
 
257
+ /**
258
+ * `ledger outside-review --run <r> --pr <n> --rounds <n> --findings <n> [--link <url>]
259
+ * [--block <id>]...` (B66). Usage errors exit 2, an unknown run or block exits 1; both write nothing.
260
+ * The run key is loaded (and so registered as a secret) BEFORE the link is redacted.
261
+ * @param {string[]} args - after `outside-review`.
262
+ * @param {{out: (s: string) => void, err: (s: string) => void}} io
263
+ * @returns {Promise<number>}
264
+ */
265
+ async function addOutsideReview(args, { out, err }) {
266
+ const parsed = parseOutsideReviewArgs(args);
267
+ if ('message' in parsed) {
268
+ err(`ledger outside-review: ${parsed.message}\n${OUTSIDE_REVIEW_USAGE}\n`);
269
+ return 2;
270
+ }
271
+ const { run, pr, rounds, findings, link, blocks } = parsed.value;
272
+ try {
273
+ const record = await readRun(run);
274
+ const known = record.blocks && typeof record.blocks === 'object' ? record.blocks : {};
275
+ const unknown = blocks.find((id) => !Object.hasOwn(known, id));
276
+ if (unknown !== undefined) {
277
+ err(`ledger outside-review: block ${JSON.stringify(unknown)} is not a block of run ${run}; nothing written\n`);
278
+ return 1;
279
+ }
280
+ await loadKey(run);
281
+ const writeRow = (/** @type {Record<string, any>} */ row) => appendRow(row, { slug: record.project });
282
+ const row = { event: OUTSIDE_REVIEW_EVENT, pr, rounds, findings, link: link === null ? null : redact(link), blocks };
283
+ await writeSigned(run, writeRow, row);
284
+ out(outsideReviewRecordedLine(row));
285
+ return 0;
286
+ } catch (thrown) {
287
+ err(`ledger outside-review: ${thrown?.message ?? String(thrown)}\n`);
288
+ return 1;
289
+ }
290
+ }
291
+
248
292
  /** @param {string[]} args @returns {Promise<number>} */
249
293
  export default async function ledger(args) {
250
294
  return runLedger(args);
package/src/cli/proof.mjs CHANGED
@@ -20,6 +20,13 @@
20
20
  * `export` reads `proof.export.{link_dirs,copy_untracked}` from the workspace's `.code-forge.yml`
21
21
  * (defaults §1.3) and writes a signed `proof` row `isolation: export`; `lock`/`unlock` write
22
22
  * `isolation: lock` rows (B6 writer, B8 signer).
23
+ *
24
+ * B62b: for a block with a worktree, `export` and `red-green` read the block's files, the test
25
+ * and the base in its tree (`blockTree`, against the tree's `base_sha`) and measure in an export
26
+ * built FROM the tree into the main tree's `.code-forge/export/<block>` — never in the tree, which
27
+ * is left exactly as it was. The config still comes from the main tree. A recorded tree that is
28
+ * gone or malformed refuses (exit 1, `…; no proof was run`). `lock`, `unlock`, `restore` and
29
+ * `export --remove` touch only the run's lock and that export directory, as before.
23
30
  */
24
31
 
25
32
  import { lstat } from 'node:fs/promises';
@@ -35,6 +42,7 @@ import { DEFAULT_COPY_UNTRACKED, DEFAULT_LINK_DIRS, buildExport, exportDirFor, r
35
42
  import { acquireProofLock, lockHolder, releaseProofLock } from '../proof/lock.mjs';
36
43
  import { MECHANISMS, readJournal, restoreJournal, runRedGreen } from '../proof/red-green.mjs';
37
44
  import { tierFor } from '../proof/tiers.mjs';
45
+ import { blockTree } from '../proof/worktree.mjs';
38
46
  import { blockFileSet, repoRelativePath } from '../review/gate-check.mjs';
39
47
  import { ownsFile } from '../state/registry.mjs';
40
48
 
@@ -117,23 +125,53 @@ async function configAt(cwd) {
117
125
  }
118
126
 
119
127
  /**
120
- * Build the block's measurement export and write its signed `proof` row (`step: export`).
121
- * @param {{runId: string, record: Record<string, any>, blockId: string, block: Record<string, any>, config: Record<string, any>, writeRow: (row: Record<string, any>) => Promise<unknown>}} opts
128
+ * @typedef {object} ProofPlace
129
+ * @property {string} dir - where the block's files, its test and its base are read: the workspace,
130
+ * or the workspace's place in the block's worktree.
131
+ * @property {string} base - the block's `base_sha`, or its tree's.
132
+ * @property {string | null} tree - the block's worktree, null without one.
133
+ */
134
+
135
+ /**
136
+ * B62b: where a block's proof is measured. A block without a worktree: the workspace and the
137
+ * block's base, as before. A block with one: its tree (`blockTree`) and the tree's `base_sha`. A
138
+ * recorded tree that is gone or malformed refuses (exit 1), as `gates run` does.
139
+ * @param {string} runId @param {Record<string, any>} record @param {string} blockId @param {Record<string, any>} block
140
+ * @returns {Promise<ProofPlace>}
141
+ * @throws {StateError} `worktree-*`
142
+ */
143
+ async function proofPlace(runId, record, blockId, block) {
144
+ let wt;
145
+ try {
146
+ wt = await blockTree(record.workspace, block);
147
+ } catch (thrown) {
148
+ if (thrown instanceof StateError) throw new StateError(thrown.code, `block ${blockId} of run ${runId}: ${thrown.message}; no proof was run`);
149
+ throw thrown;
150
+ }
151
+ return wt === null ? { dir: record.workspace, base: block.base_sha, tree: null } : { dir: wt.workspace, base: wt.base_sha, tree: wt.path };
152
+ }
153
+
154
+ /**
155
+ * Build the block's measurement export and write its signed `proof` row (`step: export`). A block
156
+ * with a worktree is exported FROM its tree, into the main tree's `.code-forge/export/<block>`: the
157
+ * tree itself is never written to, and its row names it (`worktree`).
158
+ * @param {{runId: string, record: Record<string, any>, blockId: string, block: Record<string, any>, place: ProofPlace, config: Record<string, any>, writeRow: (row: Record<string, any>) => Promise<unknown>}} opts
122
159
  */
123
- async function exportAndRecord({ runId, record, blockId, block, config, writeRow }) {
160
+ async function exportAndRecord({ runId, record, blockId, block, place, config, writeRow }) {
124
161
  const exp = await buildExport({
125
- cwd: record.workspace,
162
+ cwd: place.dir,
126
163
  blockId,
127
- baseSha: block.base_sha,
164
+ baseSha: place.base,
128
165
  owned: block.owned_files,
129
166
  ownedKinds: block.owned_kinds,
130
167
  linkDirs: config?.proof?.export?.link_dirs ?? DEFAULT_LINK_DIRS,
131
168
  copyUntracked: config?.proof?.export?.copy_untracked ?? DEFAULT_COPY_UNTRACKED,
169
+ exportRoot: place.tree === null ? undefined : path.dirname(exportDirFor(record.workspace, blockId)),
132
170
  });
133
171
  await recordProof({
134
172
  runId,
135
173
  writeRow,
136
- row: { block: blockId, isolation: 'export', step: 'export', base_sha: exp.base, restored: exp.restored, untracked: exp.untracked },
174
+ row: { block: blockId, isolation: 'export', step: 'export', base_sha: exp.base, restored: exp.restored, untracked: exp.untracked, ...(place.tree === null ? {} : { worktree: place.tree }) },
137
175
  });
138
176
  return exp;
139
177
  }
@@ -193,6 +231,9 @@ export async function runProof(args, deps = {}) {
193
231
  const writeRow = (/** @type {Record<string, any>} */ row) => appendRow(row, { slug: record.project });
194
232
 
195
233
  if (sub === 'lock') {
234
+ // B62b: a block with its own tree never measures under the lock (see red-green)
235
+ const recorded = record.blocks?.[blockId]?.worktree;
236
+ if (recorded !== undefined && recorded !== null) throw new StateError('usage', `block ${blockId} is proven in an export built from its tree; proof lock does not apply`);
196
237
  out({ locked: await acquireProofLock({ runId, blockId, writeRow }) });
197
238
  return 0;
198
239
  }
@@ -210,8 +251,10 @@ export async function runProof(args, deps = {}) {
210
251
  return 0;
211
252
  }
212
253
  const config = await configAt(record.workspace);
213
- if (config?.proof?.isolation === 'lock') throw new StateError('isolation-lock', 'proof.isolation is lock — use proof lock instead of an export');
214
- out(await exportAndRecord({ runId, record, blockId, block, config, writeRow }));
254
+ const place = await proofPlace(runId, record, blockId, block);
255
+ // a block with its own tree is always proven in an export made from it (see red-green)
256
+ if (place.tree === null && config?.proof?.isolation === 'lock') throw new StateError('isolation-lock', 'proof.isolation is lock — use proof lock instead of an export');
257
+ out(await exportAndRecord({ runId, record, blockId, block, place, config, writeRow }));
215
258
  return 0;
216
259
  }
217
260
 
@@ -232,17 +275,20 @@ export async function runProof(args, deps = {}) {
232
275
  const record = await readRun(runId);
233
276
  const block = record.blocks?.[blockId];
234
277
  if (block?.status !== 'open') throw new StateError('no-block', `block ${blockId} is not open in run ${runId}`);
235
- await assertTestFile(record.workspace, file);
278
+ const place = await proofPlace(runId, record, blockId, block);
279
+ await assertTestFile(place.dir, file);
236
280
  const writeRow = (/** @type {Record<string, any>} */ row) => appendRow(row, { slug: record.project });
237
281
  const config = await configAt(record.workspace);
238
- const isolation = config?.proof?.isolation === 'lock' ? 'lock' : 'export';
282
+ // A block with its own tree is always proven in an export made from that tree (never in the
283
+ // tree itself, never under the lock), whatever the config says; otherwise lock or export.
284
+ const isolation = place.tree !== null ? 'export' : config?.proof?.isolation === 'lock' ? 'lock' : 'export';
239
285
  if (isolation === 'lock' && (await lockHolder(runId)) !== blockId) {
240
286
  throw new StateError('proof.busy', `proof.isolation is lock — run code-forge proof lock ${blockId} --run ${runId} first`);
241
287
  }
242
288
  // `revert` puts every changed non-test owned file back to base; `assertion-deletion` touches only the test.
243
289
  const changed = await blockFileSet({
244
- repoRoot: record.workspace,
245
- base: block.base_sha,
290
+ repoRoot: place.dir,
291
+ base: place.base,
246
292
  owned: block.owned_files,
247
293
  matchOwned: (f) => ownsFile(block.owned_files, f, block.owned_kinds),
248
294
  });
@@ -250,12 +296,12 @@ export async function runProof(args, deps = {}) {
250
296
  if (mechanism === 'revert' && sources.length === 0) {
251
297
  throw new StateError('usage', `block ${blockId} changed no non-test source file to revert — use --mechanism assertion-deletion`);
252
298
  }
253
- const exp = isolation === 'export' ? await exportAndRecord({ runId, record, blockId, block, config, writeRow }) : null;
299
+ const exp = isolation === 'export' ? await exportAndRecord({ runId, record, blockId, block, place, config, writeRow }) : null;
254
300
  if (exp) await assertTestFile(exp.dir, file); // the test must also be where it runs (owned, or tracked at base)
255
301
  const result = await runRedGreen({
256
- workDir: exp ? exp.dir : record.workspace,
257
- repoDir: record.workspace,
258
- base: block.base_sha,
302
+ workDir: exp ? exp.dir : place.dir,
303
+ repoDir: place.dir,
304
+ base: place.base,
259
305
  argv,
260
306
  test: { file, ...(testCase === undefined ? {} : { case: testCase }), characterization: mechanism === 'assertion-deletion' },
261
307
  sources,