@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/worker/queue.mjs
CHANGED
|
@@ -9,10 +9,13 @@
|
|
|
9
9
|
*
|
|
10
10
|
* The forbidden list (§8.4 ★) denies a coder any write under `reviews/` and to `queue/*.done`;
|
|
11
11
|
* the MAC makes an edit visible anyway (T1/T2). Writes are atomic (temp file + rename).
|
|
12
|
+
*
|
|
13
|
+
* B62b: `<repo>` is always the MAIN tree's top level, where the worker runs — a block with its own
|
|
14
|
+
* worktree still queues there; only the reviewed content is read in the block's tree.
|
|
12
15
|
*/
|
|
13
16
|
|
|
14
17
|
import { randomBytes } from 'node:crypto';
|
|
15
|
-
import { existsSync, mkdirSync, readdirSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs';
|
|
18
|
+
import { existsSync, mkdirSync, readdirSync, readFileSync, realpathSync, renameSync, rmSync, writeFileSync } from 'node:fs';
|
|
16
19
|
import path from 'node:path';
|
|
17
20
|
import { loadKey, signRow, verifyRow } from '../state/signer.mjs';
|
|
18
21
|
import { isAlive, readStartTime, UNKNOWN_START_TIME } from '../util/reaper.mjs';
|
|
@@ -54,26 +57,48 @@ function readJSON(file) {
|
|
|
54
57
|
}
|
|
55
58
|
|
|
56
59
|
/**
|
|
57
|
-
* @typedef {{ticket: string, run: string, block: string, file: string, content_hash: string, enqueued_at: string}} Ticket
|
|
60
|
+
* @typedef {{ticket: string, run: string, block: string, file: string, content_hash: string, enqueued_at: string, tree?: string}} Ticket
|
|
61
|
+
* `tree` (B62b): the block's worktree the content was hashed in. It is part of the ticket id,
|
|
62
|
+
* and the worker only checks it against the tree it takes from the run record (a ticket whose
|
|
63
|
+
* tree is not that one is `unavailable`, `worktree-mismatch`); it never reads content there.
|
|
58
64
|
*/
|
|
59
65
|
|
|
60
66
|
/**
|
|
61
67
|
* Enqueue `file` (already repo-root-relative) for review. Idempotent by content hash: the same
|
|
62
|
-
* content returns the same ticket and never rewrites it.
|
|
63
|
-
*
|
|
64
|
-
*
|
|
68
|
+
* content returns the same ticket and never rewrites it. The queue is under `repoRoot`; the
|
|
69
|
+
* content is hashed under `tree` when given (B62b: the block's worktree, realpath'd here), else
|
|
70
|
+
* under `repoRoot`. The tree is part of the ticket id, so a main-tree ticket and a tree ticket for
|
|
71
|
+
* the same bytes differ, and the answer names the tree given here, never a stored value.
|
|
72
|
+
* @param {{repoRoot: string, run: string, block: string, file: string, tree?: string | null, now?: Date}} opts
|
|
73
|
+
* @returns {{ticket: string, status: 'queued' | 'done', file: string, content_hash: string, tree?: string}}
|
|
74
|
+
* @throws {WorkerError} `bad-path` for a relative `tree` or one that cannot be resolved.
|
|
65
75
|
*/
|
|
66
|
-
export function enqueue({ repoRoot, run, block, file, now = new Date() }) {
|
|
76
|
+
export function enqueue({ repoRoot, run, block, file, tree = null, now = new Date() }) {
|
|
67
77
|
const rel = assertRowPath(file);
|
|
68
|
-
const
|
|
69
|
-
const
|
|
78
|
+
const root = tree === null ? null : realTree(tree);
|
|
79
|
+
const content_hash = contentHash(root ?? repoRoot, rel);
|
|
80
|
+
const ticket = ticketId({ run, block, file: rel, content_hash, tree: root });
|
|
70
81
|
const target = path.join(queueDir(repoRoot), `${ticket}.json`);
|
|
82
|
+
const named = root === null ? {} : { tree: root };
|
|
71
83
|
if (!existsSync(target)) {
|
|
72
84
|
/** @type {Ticket} */
|
|
73
|
-
const body = { ticket, run, block, file: rel, content_hash, enqueued_at: now.toISOString() };
|
|
85
|
+
const body = { ticket, run, block, file: rel, content_hash, enqueued_at: now.toISOString(), ...named };
|
|
74
86
|
writeAtomic(target, `${JSON.stringify(body)}\n`);
|
|
75
87
|
}
|
|
76
|
-
return { ticket, status: isDone(repoRoot, ticket) ? 'done' : 'queued', file: rel, content_hash };
|
|
88
|
+
return { ticket, status: isDone(repoRoot, ticket) ? 'done' : 'queued', file: rel, content_hash, ...named };
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* @param {string} tree @returns {string} `tree` realpath'd.
|
|
93
|
+
* @throws {WorkerError} `bad-path` when it is relative or cannot be resolved.
|
|
94
|
+
*/
|
|
95
|
+
function realTree(tree) {
|
|
96
|
+
if (!path.isAbsolute(tree)) throw new WorkerError('bad-path', `the content tree ${tree} is not an absolute path`);
|
|
97
|
+
try {
|
|
98
|
+
return realpathSync(tree);
|
|
99
|
+
} catch {
|
|
100
|
+
throw new WorkerError('bad-path', `the content tree ${tree} cannot be resolved`);
|
|
101
|
+
}
|
|
77
102
|
}
|
|
78
103
|
|
|
79
104
|
/** @param {string} repoRoot @param {string} ticket */
|
|
@@ -113,6 +138,7 @@ export function readTicket(repoRoot, id) {
|
|
|
113
138
|
const t = readJSON(path.join(queueDir(repoRoot), `${assertTicketId(id)}.json`));
|
|
114
139
|
if (!t || typeof t !== 'object') throw new WorkerError('bad-ticket', `ticket ${id} is not readable JSON`);
|
|
115
140
|
assertRowPath(t.file);
|
|
141
|
+
if (t.tree !== undefined && (typeof t.tree !== 'string' || !path.isAbsolute(t.tree))) throw new WorkerError('bad-ticket', `ticket ${id} names a tree that is not an absolute path`);
|
|
116
142
|
if (typeof t.run !== 'string' || typeof t.block !== 'string' || typeof t.content_hash !== 'string' || ticketId(t) !== id) {
|
|
117
143
|
throw new WorkerError('bad-ticket', `ticket ${id} does not match its id`);
|
|
118
144
|
}
|
package/src/worker/ticket.mjs
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Review tickets (plan §4.8, V4; block B11): repo-root-relative paths, content hashes and ticket
|
|
3
|
-
* ids. Pure functions plus the
|
|
3
|
+
* ids. Pure functions plus the `git rev-parse` calls that find the repository root (and, B62b,
|
|
4
|
+
* the main working tree of a linked worktree: `mainRootOf`).
|
|
4
5
|
*
|
|
5
6
|
* Path rule (V4): a ticket's `file` — and every `review.*` row the worker writes — is relative to
|
|
6
7
|
* the REPOSITORY ROOT (realpath'd `git rev-parse --show-toplevel`), whatever directory the coder
|
|
@@ -8,8 +9,9 @@
|
|
|
8
9
|
* `bad-path` before anything is written; so is a result outside the root or under `.git/` or
|
|
9
10
|
* `.code-forge/`. A stored value is re-checked by `assertRowPath` (tickets are coder-writable).
|
|
10
11
|
*
|
|
11
|
-
* Ticket id = the first 24 hex chars of `sha256(run \0 block \0 file \0 content_hash)
|
|
12
|
-
* content
|
|
12
|
+
* Ticket id = the first 24 hex chars of `sha256(run \0 block \0 file \0 content_hash)`, plus
|
|
13
|
+
* `\0 tree` for content hashed in a block's worktree (B62b): the same content enqueued twice is
|
|
14
|
+
* the same ticket (idempotent), an edited file is a new one.
|
|
13
15
|
*/
|
|
14
16
|
|
|
15
17
|
import { createHash } from 'node:crypto';
|
|
@@ -73,6 +75,39 @@ export async function repoRootOf(cwd) {
|
|
|
73
75
|
return realpathSync(top);
|
|
74
76
|
}
|
|
75
77
|
|
|
78
|
+
/**
|
|
79
|
+
* B62b: the top level of the MAIN working tree of the repository containing `cwd`, realpath'd —
|
|
80
|
+
* `cwd`'s own top level ({@link repoRootOf}) unless `cwd` is inside a linked worktree (`git
|
|
81
|
+
* worktree add`), whose main tree is the first record `git worktree list` prints. The review queue
|
|
82
|
+
* and the worker live there, whichever tree the coder works in. A repository whose main entry is
|
|
83
|
+
* bare has no main working tree to hold them, and is refused.
|
|
84
|
+
* @param {string} cwd
|
|
85
|
+
* @returns {Promise<string>}
|
|
86
|
+
* @throws {WorkerError} `no-repo` (also for git older than 2.36), `bare-main`, `no-main-tree`
|
|
87
|
+
*/
|
|
88
|
+
export async function mainRootOf(cwd) {
|
|
89
|
+
const top = await repoRootOf(cwd);
|
|
90
|
+
const opts = { cwd, env: gitChildEnv(), timeoutMs: 10000 };
|
|
91
|
+
const dirs = await exec(['git', 'rev-parse', '--path-format=absolute', '--git-dir', '--git-common-dir'], opts);
|
|
92
|
+
if (dirs.result !== 'ok') throw new WorkerError('no-repo', `not inside a git repository, or git is older than 2.36 (code-forge needs git >= 2.36): ${dirs.stderr.trim()}`);
|
|
93
|
+
const [gitDir, commonDir] = dirs.stdout.split('\n');
|
|
94
|
+
if (!gitDir || !commonDir) throw new WorkerError('no-repo', 'git rev-parse printed no git directory');
|
|
95
|
+
if (realOrSelf(gitDir) === realOrSelf(commonDir)) return top;
|
|
96
|
+
const list = await exec(['git', 'worktree', 'list', '--porcelain', '-z'], opts);
|
|
97
|
+
if (list.result !== 'ok') throw new WorkerError('no-repo', `the main working tree of the repository cannot be listed (code-forge needs git >= 2.36): ${list.stderr.trim()}`);
|
|
98
|
+
// records end with an empty field; the first record is the main tree's
|
|
99
|
+
const first = list.stdout.split('\0\0')[0].split('\0');
|
|
100
|
+
const where = first.find((line) => line.startsWith('worktree '));
|
|
101
|
+
if (!where) throw new WorkerError('no-repo', 'git worktree list printed no main working tree');
|
|
102
|
+
if (first.includes('bare')) throw new WorkerError('bare-main', `the repository's main entry ${where.slice('worktree '.length)} is bare: it has no main working tree for the review queue`);
|
|
103
|
+
const main = where.slice('worktree '.length);
|
|
104
|
+
try {
|
|
105
|
+
return realpathSync(main);
|
|
106
|
+
} catch {
|
|
107
|
+
throw new WorkerError('no-main-tree', `the repository's main working tree ${main} does not exist (moved or removed): the review queue lives there`);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
76
111
|
/**
|
|
77
112
|
* Refuse a stored row path that is not a plain repo-root-relative POSIX path (V4).
|
|
78
113
|
* @param {unknown} value
|
|
@@ -187,9 +222,14 @@ export function readRegularFileNoFollow(repoRoot, file) {
|
|
|
187
222
|
}
|
|
188
223
|
|
|
189
224
|
/**
|
|
190
|
-
*
|
|
225
|
+
* B62b: a ticket that names the tree its content was hashed in (`tree`, a block's worktree) has
|
|
226
|
+
* the tree in its identity too — `sha256(run \0 block \0 file \0 content_hash \0 tree)` — so a
|
|
227
|
+
* main-tree ticket and a tree ticket for the same bytes are two tickets. Without a tree the id is
|
|
228
|
+
* the one from before B62b.
|
|
229
|
+
* @param {{run: string, block: string, file: string, content_hash: string, tree?: string | null}} t
|
|
191
230
|
* @returns {string} the ticket id.
|
|
192
231
|
*/
|
|
193
|
-
export function ticketId({ run, block, file, content_hash }) {
|
|
194
|
-
|
|
232
|
+
export function ticketId({ run, block, file, content_hash, tree = null }) {
|
|
233
|
+
const parts = tree === null ? [run, block, file, content_hash] : [run, block, file, content_hash, tree];
|
|
234
|
+
return createHash('sha256').update(parts.join('\0')).digest('hex').slice(0, 24);
|
|
195
235
|
}
|