@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
@@ -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
- * @param {{repoRoot: string, run: string, block: string, file: string, now?: Date}} opts
64
- * @returns {{ticket: string, status: 'queued' | 'done', file: string, content_hash: string}}
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 content_hash = contentHash(repoRoot, rel);
69
- const ticket = ticketId({ run, block, file: rel, content_hash });
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
  }
@@ -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 one `git rev-parse --show-toplevel` call.
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)`: the same
12
- * content enqueued twice is the same ticket (idempotent), an edited file is a new one.
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
- * @param {{run: string, block: string, file: string, content_hash: string}} t
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
- return createHash('sha256').update([run, block, file, content_hash].join('\0')).digest('hex').slice(0, 24);
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
  }