@codedology/code-forge 0.7.7 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (59) hide show
  1. package/CHANGELOG.md +41 -0
  2. package/README.md +50 -2
  3. package/docs/README.md +2 -1
  4. package/docs/reference/blocks.json +157 -1
  5. package/docs/reference/config.md +26 -8
  6. package/package.json +1 -1
  7. package/schema/code-forge.schema.json +81 -4
  8. package/skill/SKILL.md +6 -6
  9. package/skill/references/code.md +33 -4
  10. package/skill/references/ledger.md +8 -4
  11. package/skill/references/proof.md +44 -3
  12. package/skill/references/review.md +4 -2
  13. package/skill/references/security.md +1 -1
  14. package/skill/scripts/forge +1 -1
  15. package/skill/templates/coder-brief.md +18 -1
  16. package/src/autopilot/binnacle.mjs +21 -3
  17. package/src/cli/block.mjs +292 -33
  18. package/src/cli/context.mjs +175 -0
  19. package/src/cli/gates.mjs +125 -15
  20. package/src/cli/ledger.mjs +46 -2
  21. package/src/cli/proof.mjs +62 -16
  22. package/src/cli/report.mjs +7 -4
  23. package/src/cli/review-file.mjs +61 -6
  24. package/src/cli/status.mjs +212 -0
  25. package/src/config/validate.mjs +45 -11
  26. package/src/context/pack.mjs +319 -0
  27. package/src/doctor/local.mjs +2 -1
  28. package/src/engines/sentinel.mjs +27 -6
  29. package/src/gates/detect.mjs +21 -8
  30. package/src/gates/expand.mjs +202 -0
  31. package/src/gates/owned-count.mjs +66 -0
  32. package/src/gates/run.mjs +49 -12
  33. package/src/install/wizard/answers.mjs +34 -5
  34. package/src/install/wizard/flags.mjs +8 -1
  35. package/src/install/wizard/project-file.mjs +2 -0
  36. package/src/install/wizard/run.mjs +6 -0
  37. package/src/install/wizard/steps.mjs +24 -4
  38. package/src/install/wizard/summary.mjs +13 -5
  39. package/src/ledger/export.mjs +1 -1
  40. package/src/ledger/outside-review.mjs +164 -0
  41. package/src/ledger/report.mjs +17 -5
  42. package/src/proof/export.mjs +93 -10
  43. package/src/proof/lock.mjs +28 -5
  44. package/src/proof/worktree.mjs +482 -0
  45. package/src/review/gate-check.mjs +9 -5
  46. package/src/review/open-findings.mjs +59 -0
  47. package/src/state/block-end.mjs +290 -0
  48. package/src/state/block.mjs +103 -39
  49. package/src/state/brief-status.mjs +131 -0
  50. package/src/state/config-snapshot.mjs +51 -0
  51. package/src/state/registry.mjs +35 -4
  52. package/src/state/run.mjs +10 -1
  53. package/src/util/breadcrumbs.mjs +1 -0
  54. package/src/util/forbidden.mjs +36 -1
  55. package/src/util/gate-token.mjs +8 -0
  56. package/src/worker/engine.mjs +157 -17
  57. package/src/worker/loop.mjs +26 -7
  58. package/src/worker/queue.mjs +36 -10
  59. package/src/worker/ticket.mjs +46 -6
@@ -1,5 +1,5 @@
1
1
  /**
2
- * `code-forge report` (plan §6.2, R4): builds the 13 report sections from a run's ledger rows.
2
+ * `code-forge report` (plan §6.2, R4): builds the 14 report sections from a run's ledger rows.
3
3
  * Pure data, no I/O — terminal rendering + `--export` live in `src/cli/report.mjs`.
4
4
  *
5
5
  * Shadow rows (`row.source === 'shadow'`) are excluded everywhere here (plan §6.3: shadow-sampled
@@ -13,9 +13,14 @@
13
13
  * `session.background` rows, whose price is unknown); its dollars come from `./spend.mjs`
14
14
  * (`usd`, else `cost_usd`, else ESTIMATED from its tokens × `./prices.mjs`); `review.done` {block,file,context_mode,findings_by_severity,round}; `decision`
15
15
  * {block,question,source}; `run.start` {run}; `run.mode` {run,mode,plan} (B59, `buildRunModes`); `outcome` {block,missed_after_degrade}; `escalation` {block,trigger};
16
- * `review.unavailable` {block,reason}; `proof` {block,duration_ms}; `run.stop` {reason}.
16
+ * `review.unavailable` {block,reason}; `proof` {block,duration_ms}; `run.stop` {reason};
17
+ * `outside_review` {run,pr,rounds,findings,link,blocks} (B66, `./outside-review.mjs`).
18
+ *
19
+ * Signatures are not verified here: like every other section, `outside_review` reads the rows as
20
+ * the ledger holds them (an unsigned or tampered row is counted, not dropped).
17
21
  */
18
22
 
23
+ import { buildOutsideReview } from './outside-review.mjs';
19
24
  import { isSpendRow, rowUsd, spendByRun, sumSpend } from './spend.mjs';
20
25
 
21
26
  const ROLE_BUCKET = Object.freeze({
@@ -51,7 +56,7 @@ export function sessionCostUsd(row) {
51
56
 
52
57
  /**
53
58
  * B33: spend per run (running totals, open blocks included) and over the whole ledger — kept
54
- * beside the 13 sections, not as a 14th (`SECTION_NAMES` stays pinned at 13).
59
+ * beside the sections, never one of them (text only, not in `SECTION_NAMES`).
55
60
  * @param {Array<Record<string, any>>} rows
56
61
  * @returns {{runs: Array<{run: string | null, usd: number, unknown: number, sessions: number}>, total: {usd: number, unknown: number, sessions: number}}}
57
62
  */
@@ -257,7 +262,8 @@ const runStopCounts = (rows) => ({
257
262
  noEngineStops: rows.filter((r) => r.event === 'run.stop' && r.reason === 'no_engine').length,
258
263
  });
259
264
 
260
- /** The exact, ordered set of section keys `buildReport` produces (plan §6.2: "13 sections") —
265
+ /** The exact, ordered set of section keys `buildReport` produces (plan §6.2; B66 appended the
266
+ * 14th, `outside_review` — append new sections, never reorder) —
261
267
  * exported so tests (and `--export`) can assert against ONE source of truth instead of a copy. */
262
268
  export const SECTION_NAMES = Object.freeze([
263
269
  'cost_per_block',
@@ -273,6 +279,7 @@ export const SECTION_NAMES = Object.freeze([
273
279
  'forecast_vs_actual_lines',
274
280
  'blocks_stopped_at_l3',
275
281
  'run_stop_counts',
282
+ 'outside_review',
276
283
  ]);
277
284
 
278
285
  /**
@@ -281,8 +288,10 @@ export const SECTION_NAMES = Object.freeze([
281
288
  */
282
289
  export function buildReport(rows) {
283
290
  const acted = rows.filter((r) => r.source !== 'shadow');
291
+ // computed once: `cost_per_block` and `outside_review` share the same entries
292
+ const costs = costPerBlock(acted);
284
293
  const builders = {
285
- cost_per_block: costPerBlock,
294
+ cost_per_block: () => costs,
286
295
  lane_distribution: laneDistribution,
287
296
  escalations: escalationsPerBlock,
288
297
  s1_calls_per_block: s1CallsPerBlock,
@@ -295,6 +304,9 @@ export function buildReport(rows) {
295
304
  forecast_vs_actual_lines: forecastVsActualLines,
296
305
  blocks_stopped_at_l3: blocksStoppedAtL3,
297
306
  run_stop_counts: runStopCounts,
307
+ // B66: per PR, the outside review rounds next to the cost of its blocks — the SAME
308
+ // `costPerBlock` entries the `cost_per_block` section shows
309
+ outside_review: (/** @type {any[]} */ r) => buildOutsideReview(r, costs),
298
310
  };
299
311
  /** @type {Record<string, any>} */
300
312
  const sections = {};
@@ -10,7 +10,12 @@
10
10
  * (c) `proof.export.copy_untracked` paths that exist in the main tree ride along through
11
11
  * `git archive --add-file` (never anything under `.code-forge/`);
12
12
  * (d) the block's owned files at their CURRENT content (new files included, deleted ones removed);
13
- * (e) `proof.export.link_dirs` symlinked from the main tree.
13
+ * (e) `proof.export.link_dirs` symlinked from the main tree — except a Composer vendor directory
14
+ * (B61: it holds `composer/autoload_real.php`), which is COPIED. PHP resolves `__DIR__`
15
+ * through symlinks, so a linked vendor makes Composer's autoloader load the app's own classes
16
+ * (`App\…`, `Tests\…`) from the MAIN tree, not the export: another block's half-written file
17
+ * would leak into the measurement. Composer's generated paths are relative to the vendor
18
+ * directory, so the copy points at the export's own `app/`, `tests/` and `database/`.
14
19
  *
15
20
  * A gate that fails inside the export on a file the export lacks but the main tree has is
16
21
  * `export.incomplete: <path>`, never a red test (`classifyExportFailure`).
@@ -24,7 +29,8 @@
24
29
  * `checkout-index`).
25
30
  */
26
31
 
27
- import { copyFile, lstat, mkdir, mkdtemp, readlink, realpath, rm, stat, symlink } from 'node:fs/promises';
32
+ import { constants } from 'node:fs';
33
+ import { copyFile, cp, lstat, mkdir, mkdtemp, readdir, readlink, realpath, rm, stat, symlink } from 'node:fs/promises';
28
34
  import path from 'node:path';
29
35
  import { runGates } from '../gates/run.mjs';
30
36
  import { StateError } from '../state/paths.mjs';
@@ -44,6 +50,8 @@ export const ISOLATIONS = Object.freeze(/** @type {const} */ (['export', 'lock']
44
50
  const BLOCK_ID = /^[A-Za-z0-9][A-Za-z0-9._-]{0,31}$/;
45
51
  const GIT_TIMEOUT_MS = 120000;
46
52
  const STATE_DIR = '.code-forge';
53
+ /** B61: the file that makes a `link_dirs` entry a Composer vendor directory (copied, not linked). */
54
+ const COMPOSER_AUTOLOADER = path.join('composer', 'autoload_real.php');
47
55
 
48
56
  /**
49
57
  * `process.env` minus every `GIT_*`, plus `extra`.
@@ -85,6 +93,58 @@ function assertRelPath(p, key) {
85
93
  return p;
86
94
  }
87
95
 
96
+ /**
97
+ * B61: a Composer vendor directory (it holds `composer/autoload_real.php`) — copied into the export,
98
+ * never linked: PHP resolves `__DIR__` through a symlink, so a linked vendor would autoload the
99
+ * app's classes from the main tree.
100
+ * @param {string} dir @returns {Promise<boolean>}
101
+ */
102
+ async function isComposerVendor(dir) {
103
+ return (await stat(path.join(dir, COMPOSER_AUTOLOADER)).catch(() => null))?.isFile() ?? false;
104
+ }
105
+
106
+ /**
107
+ * B61: inside a copied dependency dir, every absolute symlink whose target is in the main tree
108
+ * (a Composer path repository: `vendor/acme/pkg -> <main>/packages/pkg`) is pointed at the same
109
+ * place in the export; a link to the main-tree root points at the export root. Relative links,
110
+ * links outside the main tree and links into the main tree's `.code-forge/` (the export has none)
111
+ * stay as they are.
112
+ *
113
+ * `copy` must be a real directory (refused otherwise, before anything changes), and the walk never
114
+ * goes through a symlink: a `Dirent` from `readdir` describes the entry itself (lstat semantics), so
115
+ * only real directories are entered — a link is only ever rewritten, never followed, and nothing
116
+ * outside the copy is touched.
117
+ * @param {string} copy - the copied dir in the export @param {string[]} mainRoots - the main tree, as given and real
118
+ * @param {string} dir - the export root
119
+ */
120
+ async function retargetMainTreeLinks(copy, mainRoots, dir) {
121
+ const at = await lstatOrNull(copy);
122
+ if (!at?.isDirectory()) throw new StateError('export-not-copied', `buildExport: ${copy} is not a real directory; its links are left unchanged`);
123
+ /** @type {string[]} */
124
+ const pending = [copy];
125
+ while (pending.length > 0) {
126
+ const current = /** @type {string} */ (pending.pop());
127
+ for (const entry of await readdir(current, { withFileTypes: true })) {
128
+ const entryPath = path.join(current, entry.name);
129
+ if (entry.isDirectory()) {
130
+ pending.push(entryPath);
131
+ continue;
132
+ }
133
+ if (!entry.isSymbolicLink()) continue;
134
+ const target = await readlink(entryPath);
135
+ if (!path.isAbsolute(target)) continue;
136
+ for (const root of mainRoots) {
137
+ const rel = path.relative(root, target);
138
+ if (rel === '..' || rel.startsWith(`..${path.sep}`) || path.isAbsolute(rel)) continue;
139
+ if (rel === STATE_DIR || rel.startsWith(`${STATE_DIR}${path.sep}`)) break;
140
+ await rm(entryPath, { force: true });
141
+ await symlink(rel === '' ? dir : path.join(dir, rel), entryPath);
142
+ break;
143
+ }
144
+ }
145
+ }
146
+ }
147
+
88
148
  /** @param {string} p @returns {Promise<import('node:fs').Stats | null>} */
89
149
  async function lstatOrNull(p) {
90
150
  try {
@@ -160,8 +220,8 @@ async function exportIgnoredBlobs(entries, base, cwd) {
160
220
  * cwd: string, blockId: string, baseSha: string, owned: ReadonlyArray<string>, ownedKinds?: Readonly<Record<string, string>>,
161
221
  * linkDirs?: ReadonlyArray<string>, copyUntracked?: ReadonlyArray<string>, exportRoot?: string,
162
222
  * }} opts
163
- * @returns {Promise<{dir: string, base: string, restored: string[], untracked: string[], owned: string[], removed: string[], linked: string[]}>}
164
- * repo-relative lists, sorted.
223
+ * @returns {Promise<{dir: string, base: string, restored: string[], untracked: string[], owned: string[], removed: string[], linked: string[], copied: string[]}>}
224
+ * repo-relative lists, sorted; `copied` is the `linkDirs` entries copied as Composer vendor dirs.
165
225
  */
166
226
  export async function buildExport(opts) {
167
227
  const { blockId, baseSha, owned, linkDirs = DEFAULT_LINK_DIRS, copyUntracked = DEFAULT_COPY_UNTRACKED } = opts;
@@ -268,8 +328,9 @@ export async function buildExport(opts) {
268
328
  ownedCopied.push(rel);
269
329
  }
270
330
 
271
- // (e) dependency dirs, linked from the main tree.
331
+ // (e) dependency dirs, linked from the main tree; a Composer vendor dir is copied (B61).
272
332
  const linked = [];
333
+ const copied = [];
273
334
  for (const rel of linkDirs) {
274
335
  const src = path.join(cwd, rel);
275
336
  const st = await stat(src).catch(() => null);
@@ -277,11 +338,31 @@ export async function buildExport(opts) {
277
338
  const dest = path.join(dir, rel);
278
339
  await rm(dest, { recursive: true, force: true });
279
340
  await mkdir(path.dirname(dest), { recursive: true });
341
+ const real = await realpath(src);
342
+ if (await isComposerVendor(real)) {
343
+ // Copied from its real directory, so the copy is a real directory even when the main tree's
344
+ // entry is a symlink. Relative symlinks inside (vendor/bin entries) resolve in the copy;
345
+ // absolute ones into the main tree are pointed at the export. A copy-on-write clone where
346
+ // the file system has one.
347
+ await cp(real, dest, { recursive: true, verbatimSymlinks: true, mode: constants.COPYFILE_FICLONE });
348
+ await retargetMainTreeLinks(dest, [...new Set([path.resolve(opts.cwd), cwd])], dir);
349
+ copied.push(rel);
350
+ continue;
351
+ }
280
352
  await symlink(src, dest, 'dir');
281
353
  linked.push(rel);
282
354
  }
283
355
 
284
- return { dir, base, restored: [...restored].sort(), untracked: untracked.sort(), owned: ownedCopied, removed: [...removedSet].sort(), linked: linked.sort() };
356
+ return {
357
+ dir,
358
+ base,
359
+ restored: [...restored].sort(),
360
+ untracked: untracked.sort(),
361
+ owned: ownedCopied,
362
+ removed: [...removedSet].sort(),
363
+ linked: linked.sort(),
364
+ copied: copied.sort(),
365
+ };
285
366
  } finally {
286
367
  await rm(scratch, { recursive: true, force: true });
287
368
  }
@@ -358,11 +439,13 @@ export function incompleteMessage(missing) {
358
439
 
359
440
  /**
360
441
  * Run the block's gates (B5 `runGates`) inside the export; a red gate whose output is a
361
- * missing-file error for a main-tree path is re-labelled `export.incomplete`.
362
- * @param {{gates: Parameters<typeof runGates>[0], exportDir: string, mainTree: string, timeoutMs?: number}} opts
442
+ * missing-file error for a main-tree path is re-labelled `export.incomplete`. B60: `scope` (the
443
+ * block's `testScope`) is handed to `runGates` for a `{test_files}` gate; the test paths are
444
+ * repo-relative, so they name the same files inside the export.
445
+ * @param {{gates: Parameters<typeof runGates>[0], exportDir: string, mainTree: string, timeoutMs?: number, scope?: import('../gates/expand.mjs').TestScope}} opts
363
446
  */
364
- export async function runGatesInExport({ gates, exportDir, mainTree, timeoutMs }) {
365
- const { results } = await runGates(gates, { cwd: exportDir, ...(timeoutMs ? { timeoutMs } : {}) });
447
+ export async function runGatesInExport({ gates, exportDir, mainTree, timeoutMs, scope }) {
448
+ const { results } = await runGates(gates, { cwd: exportDir, ...(timeoutMs ? { timeoutMs } : {}), ...(scope ? { scope } : {}) });
366
449
  /** @type {{gate: string, path: string, message: string}[]} */
367
450
  const incomplete = [];
368
451
  const labelled = [];
@@ -16,6 +16,8 @@
16
16
  * with `isolation: lock` through the B6 writer.
17
17
  */
18
18
 
19
+ import { hasTestFilesItem, testScope } from '../gates/expand.mjs';
20
+ import { ownedFileCount } from '../gates/owned-count.mjs';
19
21
  import { runGates } from '../gates/run.mjs';
20
22
  import { StateError } from '../state/paths.mjs';
21
23
  import { readRun, saveRun, withRunLock } from '../state/run.mjs';
@@ -129,10 +131,31 @@ export async function withGateSlot({ runId, blockId, fn, pid = process.pid }) {
129
131
  }
130
132
 
131
133
  /**
132
- * B5's gate runner behind the lock — the entry point a gate caller (`gates run --run --block`,
133
- * the B12 block gate) uses.
134
- * @param {{runId: string, blockId: string, gates: Parameters<typeof runGates>[0], cwd: string, timeoutMs?: number}} opts
134
+ * B5's gate runner behind the lock. A gate caller (`gates run --run --block`, the B12 block gate)
135
+ * uses it.
136
+ *
137
+ * B60: a `{test_files}` gate runs the block's related tests. These are its `test_files` (from
138
+ * `block open --tests`), else its owned entries that look like tests (`testScope`, read from the
139
+ * run record once the slot is taken). The full suite runs instead when the block owns more than
140
+ * `threshold` (`gates.full_suite_threshold_files`) files, or when no test file is known. The
141
+ * owned files are counted by `ownedFileCount` over the tracked and new (not ignored) files at
142
+ * `cwd`. Gates with no `{test_files}` item run exactly as before B60: the run record is not read
143
+ * and nothing is counted.
144
+ * @param {{runId: string, blockId: string, gates: Parameters<typeof runGates>[0], cwd: string, timeoutMs?: number, threshold?: number}} opts
135
145
  */
136
- export async function runGatesGuarded({ runId, blockId, gates, cwd, timeoutMs }) {
137
- return withGateSlot({ runId, blockId, fn: () => runGates(gates, { cwd, ...(timeoutMs ? { timeoutMs } : {}) }) });
146
+ export async function runGatesGuarded({ runId, blockId, gates, cwd, timeoutMs, threshold }) {
147
+ return withGateSlot({
148
+ runId,
149
+ blockId,
150
+ fn: async () => {
151
+ // plain gates (no `{test_files}` item) run exactly as before B60: no scope is worked out
152
+ if (!hasTestFilesItem(gates)) return runGates(gates, { cwd, ...(timeoutMs ? { timeoutMs } : {}) });
153
+ // the slot checked the block is open; it may still be gone by the time it is read again
154
+ const block = (await readRun(runId)).blocks?.[blockId];
155
+ if (!block) throw new StateError('no-block', `block ${blockId} is not in run ${runId}`);
156
+ const ownedFiles = await ownedFileCount(cwd, block.owned_files, block.owned_kinds);
157
+ const scope = testScope({ testFiles: block.test_files, owned: block.owned_files, ownedFiles, ...(threshold === undefined ? {} : { threshold }) });
158
+ return runGates(gates, { cwd, ...(timeoutMs ? { timeoutMs } : {}), scope });
159
+ },
160
+ });
138
161
  }