@codedology/code-forge 0.6.2 → 0.7.1
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 +34 -0
- package/README.md +16 -0
- package/docs/reference/blocks.json +31 -1
- package/docs/reference/config.md +1 -1
- package/package.json +1 -1
- package/schema/code-forge.schema.json +1 -1
- package/skill/SKILL.md +1 -1
- package/skill/references/adapters/claude-code.md +1 -1
- package/skill/references/adapters/solo.md +1 -1
- package/skill/references/code.md +4 -4
- package/skill/references/decisions.md +1 -1
- package/skill/references/review.md +3 -3
- package/skill/scripts/forge +1 -1
- package/src/cli/block.mjs +123 -18
- package/src/review/engine.mjs +7 -8
- package/src/review/gate-check.mjs +395 -25
- package/src/review/triage.mjs +11 -4
- package/src/review/validate-review.mjs +11 -11
- package/src/state/block.mjs +139 -2
- package/src/worker/loop.mjs +24 -2
package/src/state/block.mjs
CHANGED
|
@@ -12,7 +12,9 @@
|
|
|
12
12
|
*/
|
|
13
13
|
|
|
14
14
|
import { createHash } from 'node:crypto';
|
|
15
|
+
import { createReadStream, existsSync, lstatSync, readlinkSync, realpathSync } from 'node:fs';
|
|
15
16
|
import os from 'node:os';
|
|
17
|
+
import path from 'node:path';
|
|
16
18
|
import { BLOCK_KINDS, blockKind, dispatchLevel } from '../decide/escalation.mjs';
|
|
17
19
|
import { exec } from '../util/exec.mjs';
|
|
18
20
|
import { isAncestorArgv, revParseArgv } from '../util/git.mjs';
|
|
@@ -48,6 +50,133 @@ async function git(argv, cwd, okExitCodes) {
|
|
|
48
50
|
return res;
|
|
49
51
|
}
|
|
50
52
|
|
|
53
|
+
/** B52: the stamp of a path whose state could not be read; never equal to anything (see {@link sameStamp}). */
|
|
54
|
+
export const UNREADABLE = 'unreadable';
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* B52: a stamp of one path's current state, never following a symlink and never throwing:
|
|
58
|
+
* - `deleted` — nothing at the path;
|
|
59
|
+
* - `file:<x|->:<sha256 of the bytes>` — a regular file, with its executable bit (hash streamed);
|
|
60
|
+
* - `link:<sha256 of the target text>` — a symlink, by where it points;
|
|
61
|
+
* - `repo:<HEAD commit>:<sha256 of its changes>` — a directory holding a git repository (a
|
|
62
|
+
* checked-out submodule, a nested repo): its checked-out commit, and the status, the binary
|
|
63
|
+
* diff against HEAD and every untracked file's content (`repoStamp`) — so a repo already dirty
|
|
64
|
+
* at open and edited again later stamps differently;
|
|
65
|
+
* - `gitlink:<commit>` — a directory with no `.git` that the index records as a gitlink (an
|
|
66
|
+
* uninitialised submodule);
|
|
67
|
+
* - {@link UNREADABLE} — anything else (a plain directory, a FIFO, a read or git failure).
|
|
68
|
+
* Two stamps are the same only when the path did not change between them, and an `unreadable`
|
|
69
|
+
* stamp never matches — a read failure must never make a file look unchanged.
|
|
70
|
+
* @param {string} top - the repository top level (`repoTop`). @param {string} file - top-relative.
|
|
71
|
+
* @returns {Promise<string>}
|
|
72
|
+
*/
|
|
73
|
+
export async function treeStamp(top, file) {
|
|
74
|
+
const full = path.join(top, file);
|
|
75
|
+
let stat;
|
|
76
|
+
try {
|
|
77
|
+
stat = lstatSync(full);
|
|
78
|
+
} catch (err) {
|
|
79
|
+
return /** @type {NodeJS.ErrnoException} */ (err).code === 'ENOENT' ? 'deleted' : UNREADABLE;
|
|
80
|
+
}
|
|
81
|
+
try {
|
|
82
|
+
if (stat.isSymbolicLink()) return `link:${sha256(readlinkSync(full))}`;
|
|
83
|
+
if (stat.isFile()) return `file:${(stat.mode & 0o111) !== 0 ? 'x' : '-'}:${await fileSha256(full)}`;
|
|
84
|
+
if (stat.isDirectory() && existsSync(path.join(full, '.git'))) return await repoStamp(full);
|
|
85
|
+
if (stat.isDirectory()) return await gitlinkStamp(top, file);
|
|
86
|
+
} catch {
|
|
87
|
+
return UNREADABLE;
|
|
88
|
+
}
|
|
89
|
+
return UNREADABLE;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** @param {Buffer | string} data @returns {string} */
|
|
93
|
+
const sha256 = (data) => createHash('sha256').update(data).digest('hex');
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* B52: a file's sha256, streamed (a large file is never read whole into memory).
|
|
97
|
+
* @param {string} full @returns {Promise<string>}
|
|
98
|
+
*/
|
|
99
|
+
async function fileSha256(full) {
|
|
100
|
+
const hash = createHash('sha256');
|
|
101
|
+
for await (const chunk of createReadStream(full)) hash.update(/** @type {Buffer} */ (chunk));
|
|
102
|
+
return hash.digest('hex');
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* B52: a nested repository's stamp — its HEAD, and a sha256 over its own changes: `git status
|
|
107
|
+
* --porcelain=v1 -z`, `git diff HEAD --binary` (the content of every tracked change) and each
|
|
108
|
+
* untracked file's path and sha256. Any git failure is {@link UNREADABLE}.
|
|
109
|
+
* @param {string} dir @returns {Promise<string>}
|
|
110
|
+
*/
|
|
111
|
+
async function repoStamp(dir) {
|
|
112
|
+
const run = (/** @type {string[]} */ argv) => exec(argv, { cwd: dir, env: gitEnv(), timeoutMs: 30000 });
|
|
113
|
+
const head = await run(['git', 'rev-parse', '--verify', 'HEAD']);
|
|
114
|
+
const status = await run(['git', 'status', '--porcelain=v1', '-z', '--untracked-files=all']);
|
|
115
|
+
const diff = await run(['git', 'diff', 'HEAD', '--binary']);
|
|
116
|
+
const untracked = await run(['git', 'ls-files', '-z', '--others', '--exclude-standard']);
|
|
117
|
+
if (head.result !== 'ok' || status.result !== 'ok' || diff.result !== 'ok' || untracked.result !== 'ok') return UNREADABLE;
|
|
118
|
+
const hash = createHash('sha256').update(status.stdout).update('\0diff\0').update(diff.stdout);
|
|
119
|
+
for (const rel of untracked.stdout.split('\0').filter((f) => f.length > 0).sort()) {
|
|
120
|
+
const inner = path.join(dir, rel);
|
|
121
|
+
const st = lstatSync(inner);
|
|
122
|
+
hash.update(`\0${rel}\0`).update(st.isFile() ? await fileSha256(inner) : st.isSymbolicLink() ? `link:${sha256(readlinkSync(inner))}` : 'other');
|
|
123
|
+
}
|
|
124
|
+
return `repo:${head.stdout.trim()}:${hash.digest('hex')}`;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* B52: a directory with no `.git` of its own that the index records as a gitlink (an uninitialised
|
|
129
|
+
* submodule): `gitlink:<commit>` from `git ls-files -s`; anything else is {@link UNREADABLE}.
|
|
130
|
+
* @param {string} top @param {string} file @returns {Promise<string>}
|
|
131
|
+
*/
|
|
132
|
+
async function gitlinkStamp(top, file) {
|
|
133
|
+
const res = await exec(['git', 'ls-files', '-s', '-z', '--', file], { cwd: top, env: gitEnv(), timeoutMs: 30000 });
|
|
134
|
+
if (res.result !== 'ok') return UNREADABLE;
|
|
135
|
+
const entry = res.stdout.split('\0').find((e) => e.endsWith(`\t${file}`));
|
|
136
|
+
const m = entry ? /^160000 ([0-9a-f]{40,64}) \d+\t/.exec(entry) : null;
|
|
137
|
+
return m ? `gitlink:${m[1]}` : UNREADABLE;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* B52: whether two stamps say the path did not change: equal, and neither {@link UNREADABLE}.
|
|
142
|
+
* @param {unknown} a @param {unknown} b @returns {boolean}
|
|
143
|
+
*/
|
|
144
|
+
export const sameStamp = (a, b) => typeof a === 'string' && a === b && a !== UNREADABLE;
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* B52: the repository's top level (`git rev-parse --show-toplevel`, realpath'd) — the root both
|
|
148
|
+
* git listings run from and every stamp is taken against, so a workspace in a subdirectory of
|
|
149
|
+
* the repository lines up with the repo-root-relative paths git prints.
|
|
150
|
+
* @param {string} cwd @returns {Promise<string>}
|
|
151
|
+
* @throws {StateError} `git-failed`
|
|
152
|
+
*/
|
|
153
|
+
export async function repoTop(cwd) {
|
|
154
|
+
const res = await git(['git', 'rev-parse', '--show-toplevel'], cwd);
|
|
155
|
+
return realpathSync(res.stdout.trim());
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* B52: the tree's changes when a block opens — every file changed since `base` (deletions
|
|
160
|
+
* included) and every untracked, not ignored file, listed from the repository top level, each
|
|
161
|
+
* with its {@link treeStamp}. Recorded in the block's entry (`tree_at_open`, top-relative paths):
|
|
162
|
+
* at `block close`, a file outside the block's `owned_files` whose stamp is still the same
|
|
163
|
+
* ({@link sameStamp}) is not the coder's change (plan files, `init`'s config, a skill link…); one
|
|
164
|
+
* that changed since, or could not be read either time, is `unowned_change` (`review/gate-check.mjs`).
|
|
165
|
+
* @param {string} cwd @param {string} base - a full commit sha.
|
|
166
|
+
* @returns {Promise<Record<string, string>>}
|
|
167
|
+
* @throws {StateError} `git-failed`
|
|
168
|
+
*/
|
|
169
|
+
export async function treeAtOpen(cwd, base) {
|
|
170
|
+
const top = await repoTop(cwd);
|
|
171
|
+
const changed = await git(['git', 'diff', '--name-only', '-z', '--no-renames', base, '--'], top);
|
|
172
|
+
const untracked = await git(['git', 'ls-files', '-z', '--others', '--exclude-standard'], top);
|
|
173
|
+
const files = [...new Set([...changed.stdout.split('\0'), ...untracked.stdout.split('\0')].filter((f) => f.length > 0))].sort();
|
|
174
|
+
/** @type {Record<string, string>} */
|
|
175
|
+
const out = {};
|
|
176
|
+
for (const file of files) out[file] = await treeStamp(top, file);
|
|
177
|
+
return out;
|
|
178
|
+
}
|
|
179
|
+
|
|
51
180
|
/** @param {string} ref @param {string} cwd @returns {Promise<string>} the full commit sha */
|
|
52
181
|
async function resolveCommit(ref, cwd) {
|
|
53
182
|
if (typeof ref !== 'string' || ref.length === 0 || ref.startsWith('-')) {
|
|
@@ -130,7 +259,8 @@ function activeBlocks(record) {
|
|
|
130
259
|
* block kind (B34: the declared `kind`, else `blockKind` of the owned files). A docs/contract
|
|
131
260
|
* block asked for below `levels.coder_floor_docs` (default L1) is opened AT the floor: the entry
|
|
132
261
|
* and the dispatch row carry the floor level, and the row adds `requested_level` and
|
|
133
|
-
* `trigger: 'docs_floor'`.
|
|
262
|
+
* `trigger: 'docs_floor'`. B52: the entry also records `tree_at_open` ({@link treeAtOpen}), the
|
|
263
|
+
* tree's changes at open, which `block close` reads to tell the coder's changes from earlier ones.
|
|
134
264
|
* @param {{
|
|
135
265
|
* runId: string, id: string, level: string, owned: string[], acceptance: unknown,
|
|
136
266
|
* attempt?: number, base?: string, lines?: number, brief?: {path: string, content: Buffer | string},
|
|
@@ -151,6 +281,12 @@ export async function openBlock(opts) {
|
|
|
151
281
|
const dispatched = dispatchLevel({ lane: level, kind, cfg: opts.cfg });
|
|
152
282
|
const pointer = opts.brief ? briefPointer(opts.brief.path, opts.brief.content) : null;
|
|
153
283
|
|
|
284
|
+
// B52: the tree is stamped BEFORE the run lock (hashing can be slow; other block commands must
|
|
285
|
+
// not wait on it); under the lock the base is resolved again and must be the one stamped against
|
|
286
|
+
const { workspace } = await readRun(runId);
|
|
287
|
+
const stampedBase = await resolveCommit(opts.base ?? 'HEAD', workspace);
|
|
288
|
+
const treeOpen = await treeAtOpen(workspace, stampedBase);
|
|
289
|
+
|
|
154
290
|
const block = await mutateRun(runId, null, writeRow, async (record) => {
|
|
155
291
|
if (record.blocks[id]?.status === 'open') throw new StateError('already-open', `block ${id} is already open`);
|
|
156
292
|
for (const [otherId, other] of Object.entries(activeBlocks(record))) {
|
|
@@ -158,7 +294,8 @@ export async function openBlock(opts) {
|
|
|
158
294
|
if (pair) throw new StateError('overlap', `block ${id} owns ${pair[0]}, which overlaps ${pair[1]} owned by open block ${otherId}`);
|
|
159
295
|
}
|
|
160
296
|
const baseSha = await resolveCommit(opts.base ?? 'HEAD', record.workspace);
|
|
161
|
-
|
|
297
|
+
if (baseSha !== stampedBase || record.workspace !== workspace) throw new StateError('base-moved', `the base moved while block ${id} was being opened; run block open again`);
|
|
298
|
+
const entry = { block: id, base_sha: baseSha, owned_files: [...owned], level: dispatched.level, kind, attempt, opened_at: now.toISOString(), acceptance, lines_forecast: lines, status: 'open', tree_at_open: treeOpen };
|
|
162
299
|
record.blocks[id] = entry;
|
|
163
300
|
const floor = dispatched.trigger === 'docs_floor' ? { requested_level: level, trigger: 'docs_floor' } : {};
|
|
164
301
|
return {
|
package/src/worker/loop.mjs
CHANGED
|
@@ -49,6 +49,7 @@ import { logWarning } from '../util/error-log.mjs';
|
|
|
49
49
|
import { keyedLock } from '../util/locks.mjs';
|
|
50
50
|
import { writeSafe } from '../util/redact.mjs';
|
|
51
51
|
import { runRoot, setRunRoot } from '../util/tmp.mjs';
|
|
52
|
+
import { openFindings } from '../review/gate-check.mjs';
|
|
52
53
|
import { reviewTicket } from './engine.mjs';
|
|
53
54
|
import { announceWorker, beatWorker, HEARTBEAT_MS, liveWorker, pendingTickets, readTicket, retractWorker, writeResult } from './queue.mjs';
|
|
54
55
|
import { contentHash, DELETED_HASH, repoRootOf, WorkerError } from './ticket.mjs';
|
|
@@ -158,6 +159,21 @@ export function loopResult(outcome) {
|
|
|
158
159
|
return out;
|
|
159
160
|
}
|
|
160
161
|
|
|
162
|
+
/**
|
|
163
|
+
* B52: the verdict a result is recorded with (its signed file and its ledger row), always with
|
|
164
|
+
* `approved` and `open`: `approved: true, open: []` only when the outcome said so AND it lists no
|
|
165
|
+
* critical finding. Otherwise `approved: false` and `open` = ALL its findings (`openFindings`:
|
|
166
|
+
* criticals, warnings and nits; a missing or malformed id as `X<n>` by position; the worst
|
|
167
|
+
* severity per id; `[]` when it names none, which the gate refuses until a fresh approval) — so
|
|
168
|
+
* an "approved" result that names a critical is downgraded with every one of its findings open.
|
|
169
|
+
* @param {Record<string, any>} result @returns {Record<string, any>}
|
|
170
|
+
*/
|
|
171
|
+
export function recordedVerdict(result) {
|
|
172
|
+
const listed = openFindings(result.findings);
|
|
173
|
+
if (result.approved === true && !listed.some((f) => f.severity === 'critical')) return { ...result, approved: true, open: [] };
|
|
174
|
+
return { ...result, approved: false, open: listed };
|
|
175
|
+
}
|
|
176
|
+
|
|
161
177
|
/**
|
|
162
178
|
* @typedef {object} WorkerOpts
|
|
163
179
|
* @property {string} runId
|
|
@@ -351,9 +367,10 @@ export async function createWorker(opts, deps = {}) {
|
|
|
351
367
|
/**
|
|
352
368
|
* Sign and write the result + done marker, then the signed ledger row. A ledger failure does not
|
|
353
369
|
* undo the result: the done marker is what the queue reads.
|
|
354
|
-
* @param {string} id @param {Record<string, any>}
|
|
370
|
+
* @param {string} id @param {Record<string, any>} outcome - recorded through {@link recordedVerdict}.
|
|
355
371
|
*/
|
|
356
|
-
async function complete(id,
|
|
372
|
+
async function complete(id, outcome) {
|
|
373
|
+
const result = recordedVerdict(outcome);
|
|
357
374
|
const signed = writeResult({ repoRoot, runId, ticket: id, result, key });
|
|
358
375
|
try {
|
|
359
376
|
await ledger({
|
|
@@ -363,6 +380,11 @@ export async function createWorker(opts, deps = {}) {
|
|
|
363
380
|
file: result.file ?? null,
|
|
364
381
|
content_hash: result.content_hash ?? null,
|
|
365
382
|
status: result.status,
|
|
383
|
+
// B52: the gate reads the verdict from the ledger, never from the result file
|
|
384
|
+
approved: result.approved,
|
|
385
|
+
open: result.open,
|
|
386
|
+
// the gate tells `refused: other-run` (never reviewed) from other refusals by it
|
|
387
|
+
...(typeof result.reason === 'string' ? { reason: result.reason } : {}),
|
|
366
388
|
...(typeof result.trigger === 'string' ? { trigger: result.trigger } : {}),
|
|
367
389
|
...(typeof result.stopped === 'string' ? { stopped: result.stopped } : {}),
|
|
368
390
|
});
|