@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.
- package/CHANGELOG.md +41 -0
- package/README.md +50 -2
- package/docs/README.md +2 -1
- package/docs/reference/blocks.json +157 -1
- package/docs/reference/config.md +26 -8
- package/package.json +1 -1
- package/schema/code-forge.schema.json +81 -4
- package/skill/SKILL.md +6 -6
- package/skill/references/code.md +33 -4
- package/skill/references/ledger.md +8 -4
- package/skill/references/proof.md +44 -3
- package/skill/references/review.md +4 -2
- package/skill/references/security.md +1 -1
- package/skill/scripts/forge +1 -1
- package/skill/templates/coder-brief.md +18 -1
- package/src/autopilot/binnacle.mjs +21 -3
- package/src/cli/block.mjs +292 -33
- package/src/cli/context.mjs +175 -0
- package/src/cli/gates.mjs +125 -15
- package/src/cli/ledger.mjs +46 -2
- package/src/cli/proof.mjs +62 -16
- package/src/cli/report.mjs +7 -4
- package/src/cli/review-file.mjs +61 -6
- package/src/cli/status.mjs +212 -0
- package/src/config/validate.mjs +45 -11
- package/src/context/pack.mjs +319 -0
- package/src/doctor/local.mjs +2 -1
- package/src/engines/sentinel.mjs +27 -6
- package/src/gates/detect.mjs +21 -8
- package/src/gates/expand.mjs +202 -0
- package/src/gates/owned-count.mjs +66 -0
- package/src/gates/run.mjs +49 -12
- package/src/install/wizard/answers.mjs +34 -5
- package/src/install/wizard/flags.mjs +8 -1
- package/src/install/wizard/project-file.mjs +2 -0
- package/src/install/wizard/run.mjs +6 -0
- package/src/install/wizard/steps.mjs +24 -4
- package/src/install/wizard/summary.mjs +13 -5
- package/src/ledger/export.mjs +1 -1
- package/src/ledger/outside-review.mjs +164 -0
- package/src/ledger/report.mjs +17 -5
- package/src/proof/export.mjs +93 -10
- package/src/proof/lock.mjs +28 -5
- package/src/proof/worktree.mjs +482 -0
- package/src/review/gate-check.mjs +9 -5
- package/src/review/open-findings.mjs +59 -0
- package/src/state/block-end.mjs +290 -0
- package/src/state/block.mjs +103 -39
- package/src/state/brief-status.mjs +131 -0
- package/src/state/config-snapshot.mjs +51 -0
- package/src/state/registry.mjs +35 -4
- package/src/state/run.mjs +10 -1
- package/src/util/breadcrumbs.mjs +1 -0
- package/src/util/forbidden.mjs +36 -1
- package/src/util/gate-token.mjs +8 -0
- package/src/worker/engine.mjs +157 -17
- package/src/worker/loop.mjs +26 -7
- package/src/worker/queue.mjs +36 -10
- package/src/worker/ticket.mjs +46 -6
package/src/ledger/report.mjs
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* `code-forge report` (plan §6.2, R4): builds the
|
|
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
|
|
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
|
|
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:
|
|
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 = {};
|
package/src/proof/export.mjs
CHANGED
|
@@ -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 {
|
|
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 {
|
|
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
|
-
*
|
|
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 = [];
|
package/src/proof/lock.mjs
CHANGED
|
@@ -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
|
|
133
|
-
*
|
|
134
|
-
*
|
|
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({
|
|
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
|
}
|