@codedology/code-forge 0.1.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 (184) hide show
  1. package/CHANGELOG.md +64 -0
  2. package/LICENSE +21 -0
  3. package/README.md +127 -0
  4. package/bin/code-forge.mjs +145 -0
  5. package/docs/README.md +37 -0
  6. package/docs/reference/blocks.json +288 -0
  7. package/docs/reference/config.md +162 -0
  8. package/examples/first-use/README.md +11 -0
  9. package/examples/first-use/clamp.acceptance.yml +8 -0
  10. package/examples/first-use/clamp.brief.md +16 -0
  11. package/examples/first-use/coder/src/math.mjs +31 -0
  12. package/examples/first-use/coder/test/clamp.test.mjs +26 -0
  13. package/examples/node-lib/.env.example +3 -0
  14. package/examples/node-lib/.gitattributes +3 -0
  15. package/examples/node-lib/.github/workflows/test.yml +11 -0
  16. package/examples/node-lib/CHANGELOG.md +13 -0
  17. package/examples/node-lib/README.md +17 -0
  18. package/examples/node-lib/gitignore +3 -0
  19. package/examples/node-lib/package.json +19 -0
  20. package/examples/node-lib/src/list.mjs +23 -0
  21. package/examples/node-lib/src/math.mjs +21 -0
  22. package/examples/node-lib/src/strings.mjs +34 -0
  23. package/examples/node-lib/test/list.test.mjs +12 -0
  24. package/examples/node-lib/test/math.test.mjs +13 -0
  25. package/examples/node-lib/test/package.test.mjs +18 -0
  26. package/examples/node-lib/test/strings.test.mjs +28 -0
  27. package/package.json +52 -0
  28. package/schema/code-forge.schema.json +331 -0
  29. package/schema/facts.schema.json +28 -0
  30. package/scripts/first-use.mjs +517 -0
  31. package/scripts/gen-config-doc.mjs +241 -0
  32. package/skill/SKILL.md +79 -0
  33. package/skill/references/adapters/claude-code.md +19 -0
  34. package/skill/references/adapters/solo.md +34 -0
  35. package/skill/references/adapters/subprocess.md +15 -0
  36. package/skill/references/code.md +64 -0
  37. package/skill/references/continuity.md +31 -0
  38. package/skill/references/decisions.md +50 -0
  39. package/skill/references/degraded.md +40 -0
  40. package/skill/references/harden.md +26 -0
  41. package/skill/references/ledger.md +23 -0
  42. package/skill/references/plan.md +43 -0
  43. package/skill/references/proof.md +34 -0
  44. package/skill/references/review.md +42 -0
  45. package/skill/references/rules-core.md +72 -0
  46. package/skill/references/security.md +29 -0
  47. package/skill/scripts/forge +40 -0
  48. package/skill/templates/block-record.md +19 -0
  49. package/skill/templates/facts-sheet.md +22 -0
  50. package/skill/templates/plan.md +53 -0
  51. package/src/cli/author.mjs +87 -0
  52. package/src/cli/block.mjs +262 -0
  53. package/src/cli/doctor.mjs +74 -0
  54. package/src/cli/facts.mjs +124 -0
  55. package/src/cli/gates.mjs +280 -0
  56. package/src/cli/help.mjs +48 -0
  57. package/src/cli/init.mjs +14 -0
  58. package/src/cli/jev.mjs +175 -0
  59. package/src/cli/keys.mjs +170 -0
  60. package/src/cli/ledger.mjs +160 -0
  61. package/src/cli/list.mjs +68 -0
  62. package/src/cli/models.mjs +131 -0
  63. package/src/cli/plan.mjs +70 -0
  64. package/src/cli/proof.mjs +316 -0
  65. package/src/cli/remove.mjs +76 -0
  66. package/src/cli/report.mjs +73 -0
  67. package/src/cli/resolve.mjs +79 -0
  68. package/src/cli/review-file.mjs +152 -0
  69. package/src/cli/run.mjs +159 -0
  70. package/src/cli/s2.mjs +93 -0
  71. package/src/cli/spawn.mjs +104 -0
  72. package/src/cli/upgrade.mjs +103 -0
  73. package/src/cli/validate.mjs +108 -0
  74. package/src/cli/version.mjs +18 -0
  75. package/src/cli/worker.mjs +76 -0
  76. package/src/config/defaults/anthropic.mjs +18 -0
  77. package/src/config/defaults/index.mjs +24 -0
  78. package/src/config/defaults/openai.mjs +16 -0
  79. package/src/config/defaults/xai.mjs +18 -0
  80. package/src/config/known-ids.mjs +171 -0
  81. package/src/config/load.mjs +135 -0
  82. package/src/config/migrate.mjs +46 -0
  83. package/src/config/refresh.mjs +276 -0
  84. package/src/config/schema-compile.mjs +211 -0
  85. package/src/config/schema-paths.mjs +84 -0
  86. package/src/config/secret-patterns.mjs +85 -0
  87. package/src/config/validate.mjs +694 -0
  88. package/src/decide/author-loop.mjs +90 -0
  89. package/src/decide/escalation.mjs +262 -0
  90. package/src/decide/fallback-rules.mjs +172 -0
  91. package/src/decide/jev-client.mjs +216 -0
  92. package/src/decide/questions.mjs +161 -0
  93. package/src/decide/thresholds.mjs +199 -0
  94. package/src/doctor/index.mjs +105 -0
  95. package/src/doctor/local.mjs +162 -0
  96. package/src/doctor/probes.mjs +358 -0
  97. package/src/doctor/rows.mjs +51 -0
  98. package/src/doctor/worker-probe.mjs +125 -0
  99. package/src/engines/alias-map.mjs +78 -0
  100. package/src/engines/builders/claude.mjs +224 -0
  101. package/src/engines/builders/codex.mjs +242 -0
  102. package/src/engines/builders/grok.mjs +132 -0
  103. package/src/engines/builders/index.mjs +35 -0
  104. package/src/engines/builders/validate-params.mjs +34 -0
  105. package/src/engines/codex-home.mjs +230 -0
  106. package/src/engines/isolation.mjs +84 -0
  107. package/src/engines/probe.mjs +116 -0
  108. package/src/engines/provider-cli.mjs +24 -0
  109. package/src/engines/render-rules.mjs +41 -0
  110. package/src/engines/sentinel.mjs +133 -0
  111. package/src/engines/usage-parse.mjs +149 -0
  112. package/src/gates/acceptance.mjs +95 -0
  113. package/src/gates/detect.mjs +187 -0
  114. package/src/gates/invariants.mjs +43 -0
  115. package/src/gates/run.mjs +72 -0
  116. package/src/gates/safe-edit.mjs +266 -0
  117. package/src/gates/scope.mjs +154 -0
  118. package/src/gates/secret-scan.mjs +79 -0
  119. package/src/gates/transcript-grep.mjs +27 -0
  120. package/src/install/agent-env.mjs +76 -0
  121. package/src/install/detect.mjs +121 -0
  122. package/src/install/harnesses.mjs +131 -0
  123. package/src/install/link.mjs +463 -0
  124. package/src/install/wizard/answers.mjs +389 -0
  125. package/src/install/wizard/flags.mjs +248 -0
  126. package/src/install/wizard/project-file.mjs +128 -0
  127. package/src/install/wizard/run.mjs +366 -0
  128. package/src/install/wizard/steps.mjs +201 -0
  129. package/src/keys/backends/constants.mjs +7 -0
  130. package/src/keys/backends/file.mjs +99 -0
  131. package/src/keys/backends/keychain.mjs +81 -0
  132. package/src/keys/backends/security-cli.mjs +105 -0
  133. package/src/keys/onepassword.mjs +46 -0
  134. package/src/keys/store.mjs +347 -0
  135. package/src/ledger/calibration.mjs +109 -0
  136. package/src/ledger/export.mjs +52 -0
  137. package/src/ledger/outcome.mjs +397 -0
  138. package/src/ledger/paths.mjs +30 -0
  139. package/src/ledger/prices.mjs +49 -0
  140. package/src/ledger/report.mjs +250 -0
  141. package/src/ledger/write.mjs +279 -0
  142. package/src/proof/export.mjs +358 -0
  143. package/src/proof/lock.mjs +138 -0
  144. package/src/proof/red-green.mjs +519 -0
  145. package/src/proof/red-parse.mjs +103 -0
  146. package/src/proof/tiers.mjs +62 -0
  147. package/src/review/budget.mjs +241 -0
  148. package/src/review/context.mjs +156 -0
  149. package/src/review/engine.mjs +264 -0
  150. package/src/review/finding.schema.json +47 -0
  151. package/src/review/fixloop.mjs +467 -0
  152. package/src/review/gate-check.mjs +249 -0
  153. package/src/review/lenses/A.md +22 -0
  154. package/src/review/lenses/B.md +23 -0
  155. package/src/review/lenses/full.md +23 -0
  156. package/src/review/lenses/judge.md +21 -0
  157. package/src/review/lenses/quick.md +21 -0
  158. package/src/review/lenses/recheck.md +23 -0
  159. package/src/review/packet.mjs +355 -0
  160. package/src/review/triage.mjs +149 -0
  161. package/src/review/validate-review.mjs +70 -0
  162. package/src/session/author.mjs +228 -0
  163. package/src/session/facts.mjs +855 -0
  164. package/src/session/plan-check.mjs +294 -0
  165. package/src/session/s2.mjs +164 -0
  166. package/src/session/spawn.mjs +610 -0
  167. package/src/state/block.mjs +289 -0
  168. package/src/state/cli-args.mjs +67 -0
  169. package/src/state/paths.mjs +92 -0
  170. package/src/state/registry.mjs +189 -0
  171. package/src/state/run.mjs +266 -0
  172. package/src/state/signer.mjs +132 -0
  173. package/src/util/exec.mjs +315 -0
  174. package/src/util/forbidden.mjs +787 -0
  175. package/src/util/git.mjs +381 -0
  176. package/src/util/log.mjs +100 -0
  177. package/src/util/reaper.mjs +300 -0
  178. package/src/util/redact.mjs +212 -0
  179. package/src/util/tmp.mjs +409 -0
  180. package/src/worker/engine.mjs +338 -0
  181. package/src/worker/loop.mjs +392 -0
  182. package/src/worker/queue.mjs +186 -0
  183. package/src/worker/review-state.mjs +145 -0
  184. package/src/worker/ticket.mjs +195 -0
@@ -0,0 +1,289 @@
1
+ /**
2
+ * Block state verbs (plan §4.9): `open`, `attempt`, `rebase`, `claim`, `scope`, `close`, `stop`,
3
+ * and the brief pointer. Every mutation runs under the run lock and writes its SIGNED ledger row
4
+ * through the `writeRow` parameter BEFORE saving the registry in the authoritative run record
5
+ * (a failed ledger write leaves the registry unchanged). `closeBlock` reads the pin and the rows
6
+ * outside the lock and re-checks status and orphans inside it before closing.
7
+ *
8
+ * `closeBlock` is B8's share of the block gate (§4.6): the worker pin (row 9/§4.8), the MAC of
9
+ * every gate-relevant row (row 10), and the orphan rule (row 1). The other rows (reviews, gates,
10
+ * acceptance, proof, forecast) arrive as `extraChecks` from the blocks that own them (B5, B10,
11
+ * B12) — this module never imports them (C17).
12
+ */
13
+
14
+ import { createHash } from 'node:crypto';
15
+ import os from 'node:os';
16
+ import { exec } from '../util/exec.mjs';
17
+ import { isAncestorArgv, revParseArgv } from '../util/git.mjs';
18
+ import { StateError } from './paths.mjs';
19
+ import { assertOwned, findOverlap, scopeGate } from './registry.mjs';
20
+ import { checkWorkerPin, readRun, saveRun, withRunLock, writeSigned } from './run.mjs';
21
+ import { verifyRow, loadKey } from './signer.mjs';
22
+
23
+ /** @typedef {import('./run.mjs').WriteRow} WriteRow */
24
+
25
+ export const BRIEF_POINTER_MAX_BYTES = 200;
26
+
27
+ /** Ledger events the block gate requires to carry a valid MAC (§4.6 row 10). */
28
+ export const GATE_EVENTS = Object.freeze(['dispatch', 'block.attempt', 'block.rebase', 'block.claim', 'review.approved', 'proof']);
29
+
30
+ const BLOCK_ID = /^[A-Za-z0-9][A-Za-z0-9._-]{0,31}$/;
31
+ const LEVEL = /^L[0-3]$/;
32
+
33
+ /** `process.env` minus every inherited `GIT_*` (hooks export GIT_DIR…), with no system/global config. */
34
+ function gitEnv() {
35
+ /** @type {NodeJS.ProcessEnv} */
36
+ const env = { GIT_CONFIG_NOSYSTEM: '1', GIT_CONFIG_GLOBAL: os.devNull };
37
+ for (const [key, value] of Object.entries(process.env)) {
38
+ if (!key.startsWith('GIT_')) env[key] = value;
39
+ }
40
+ return env;
41
+ }
42
+
43
+ /** @param {string[]} argv @param {string} cwd @param {number[]} [okExitCodes] */
44
+ async function git(argv, cwd, okExitCodes) {
45
+ const res = await exec(argv, { cwd, env: gitEnv(), okExitCodes, timeoutMs: 30000 });
46
+ if (res.result !== 'ok') throw new StateError('git-failed', `${argv.slice(0, 2).join(' ')} failed (exit ${res.code})`);
47
+ return res;
48
+ }
49
+
50
+ /** @param {string} ref @param {string} cwd @returns {Promise<string>} the full commit sha */
51
+ async function resolveCommit(ref, cwd) {
52
+ if (typeof ref !== 'string' || ref.length === 0 || ref.startsWith('-')) {
53
+ throw new StateError('bad-ref', `a git ref must be non-empty and not start with "-" (got ${JSON.stringify(ref)})`);
54
+ }
55
+ const res = await git(revParseArgv(`${ref}^{commit}`), cwd);
56
+ return res.stdout.trim();
57
+ }
58
+
59
+ /**
60
+ * `BRIEF <path> lines=<n> sha=<sha8> <<<EOM>>>` — `sha8` is the first 8 hex of the brief's
61
+ * SHA-256, `lines` counts `\n` (plus a final unterminated line). The coder answers
62
+ * `ACK <sha8> lines=<n>` (§5.3, parsed by B4).
63
+ * @param {string} relPath @param {Buffer | string} content
64
+ * @returns {string}
65
+ * @throws {StateError} `pointer-too-long` over 200 bytes, `bad-brief-path` on whitespace.
66
+ */
67
+ export function briefPointer(relPath, content) {
68
+ if (typeof relPath !== 'string' || relPath.length === 0 || /\s/.test(relPath)) {
69
+ throw new StateError('bad-brief-path', 'brief path must be non-empty and contain no whitespace');
70
+ }
71
+ const bytes = Buffer.isBuffer(content) ? content : Buffer.from(content, 'utf8');
72
+ const sha8 = createHash('sha256').update(bytes).digest('hex').slice(0, 8);
73
+ let lines = 0;
74
+ for (const byte of bytes) if (byte === 0x0a) lines += 1;
75
+ if (bytes.length > 0 && bytes[bytes.length - 1] !== 0x0a) lines += 1;
76
+ const pointer = `BRIEF ${relPath} lines=${lines} sha=${sha8} <<<EOM>>>`;
77
+ const size = Buffer.byteLength(pointer, 'utf8');
78
+ if (size > BRIEF_POINTER_MAX_BYTES) {
79
+ throw new StateError('pointer-too-long', `brief pointer is ${size} bytes (max ${BRIEF_POINTER_MAX_BYTES}); use a shorter brief path`);
80
+ }
81
+ return pointer;
82
+ }
83
+
84
+ /**
85
+ * @param {unknown} clauses
86
+ * @returns {{clause: string, tests: string[]}[]}
87
+ */
88
+ function assertAcceptance(clauses) {
89
+ const ok =
90
+ Array.isArray(clauses) &&
91
+ clauses.length > 0 &&
92
+ clauses.every(
93
+ (c) => typeof c?.clause === 'string' && c.clause.length > 0 && Array.isArray(c.tests) && c.tests.length > 0 && c.tests.every((t) => typeof t === 'string' && t.length > 0),
94
+ );
95
+ if (!ok) throw new StateError('bad-acceptance', 'acceptance: a non-empty list of {clause, tests: [test ids…]} is required');
96
+ return clauses;
97
+ }
98
+
99
+ /**
100
+ * Under the run lock: load the run, check it is active and (when `id` is given) that the block
101
+ * is open, run `mutate`, write its signed `rows` through the ledger writer, THEN save the record —
102
+ * a ledger write that fails leaves the registry unchanged.
103
+ * @template T
104
+ * @param {string} runId @param {string | null} id @param {WriteRow} writeRow
105
+ * @param {(record: Record<string, any>, block: Record<string, any> | undefined) => Promise<{result: T, rows?: Record<string, any>[]}> | {result: T, rows?: Record<string, any>[]}} mutate
106
+ * @returns {Promise<T>}
107
+ */
108
+ async function mutateRun(runId, id, writeRow, mutate) {
109
+ return withRunLock(runId, async () => {
110
+ const record = await readRun(runId);
111
+ if (record.status !== 'active') throw new StateError('run-ended', `run ${runId} has ended`);
112
+ const block = id === null ? undefined : record.blocks[id];
113
+ if (id !== null && block?.status !== 'open') throw new StateError('no-block', `block ${id} is not open in run ${runId}`);
114
+ const { result, rows = [] } = await mutate(record, block);
115
+ for (const row of rows) await writeSigned(runId, writeRow, row);
116
+ await saveRun(record);
117
+ return result;
118
+ });
119
+ }
120
+
121
+ /** @param {Record<string, any>} record @returns {Record<string, any>} the open blocks only */
122
+ function activeBlocks(record) {
123
+ return Object.fromEntries(Object.entries(record.blocks).filter(([, b]) => b.status === 'open'));
124
+ }
125
+
126
+ /**
127
+ * `block open` — refuses a second open of the same id and any owned set that overlaps another
128
+ * ACTIVE block's; records base, owned files, level, attempt, acceptance, line forecast.
129
+ * @param {{
130
+ * runId: string, id: string, level: string, owned: string[], acceptance: unknown,
131
+ * attempt?: number, base?: string, lines?: number, brief?: {path: string, content: Buffer | string},
132
+ * writeRow: WriteRow, now?: Date,
133
+ * }} opts
134
+ * @returns {Promise<{block: Record<string, any>, pointer: string | null}>}
135
+ */
136
+ export async function openBlock(opts) {
137
+ const { runId, id, level, attempt = 1, lines = null, writeRow, now = new Date() } = opts;
138
+ if (typeof id !== 'string' || !BLOCK_ID.test(id)) throw new StateError('bad-block-id', `block id must match ${BLOCK_ID}`);
139
+ if (typeof level !== 'string' || !LEVEL.test(level)) throw new StateError('bad-level', 'level must be L0..L3');
140
+ if (!Number.isInteger(attempt) || attempt < 1) throw new StateError('bad-attempt', 'attempt must be an integer >= 1');
141
+ if (lines !== null && !(Number.isInteger(lines) && lines > 0)) throw new StateError('bad-lines', 'lines forecast must be a positive integer');
142
+ const owned = assertOwned(opts.owned);
143
+ const acceptance = assertAcceptance(opts.acceptance);
144
+ const pointer = opts.brief ? briefPointer(opts.brief.path, opts.brief.content) : null;
145
+
146
+ const block = await mutateRun(runId, null, writeRow, async (record) => {
147
+ if (record.blocks[id]?.status === 'open') throw new StateError('already-open', `block ${id} is already open`);
148
+ for (const [otherId, other] of Object.entries(activeBlocks(record))) {
149
+ const pair = findOverlap(owned, other.owned_files);
150
+ if (pair) throw new StateError('overlap', `block ${id} owns ${pair[0]}, which overlaps ${pair[1]} owned by open block ${otherId}`);
151
+ }
152
+ const baseSha = await resolveCommit(opts.base ?? 'HEAD', record.workspace);
153
+ const entry = { block: id, base_sha: baseSha, owned_files: [...owned], level, attempt, opened_at: now.toISOString(), acceptance, lines_forecast: lines, status: 'open' };
154
+ record.blocks[id] = entry;
155
+ return {
156
+ result: entry,
157
+ rows: [{ event: 'dispatch', block: id, level, attempt, base_sha: baseSha, owned_files: entry.owned_files, lines_forecast: lines }],
158
+ };
159
+ });
160
+ return { block, pointer };
161
+ }
162
+
163
+ /** @param {{runId: string, id: string, writeRow: WriteRow}} opts */
164
+ export async function attemptBlock({ runId, id, writeRow }) {
165
+ return mutateRun(runId, id, writeRow, (_record, block) => {
166
+ block.attempt += 1;
167
+ return { result: block.attempt, rows: [{ event: 'block.attempt', block: id, attempt: block.attempt }] };
168
+ });
169
+ }
170
+
171
+ /**
172
+ * `block rebase` — move the base to `head` only when the old base is its ancestor and no commit
173
+ * in between touched a file this block owns (that is a conflict ⇒ stop). Rename detection is OFF
174
+ * (`--no-renames`): a rename lists both its old and its new path, so moving an owned file away
175
+ * is a conflict too. Never silent.
176
+ * @param {{runId: string, id: string, head?: string, writeRow: WriteRow}} opts
177
+ * @returns {Promise<{old_base: string, new_base: string}>}
178
+ */
179
+ export async function rebaseBlock({ runId, id, head = 'HEAD', writeRow }) {
180
+ return mutateRun(runId, id, writeRow, async (record, block) => {
181
+ const cwd = record.workspace;
182
+ const newBase = await resolveCommit(head, cwd);
183
+ const ancestor = await git(isAncestorArgv(block.base_sha, newBase), cwd, [0, 1]);
184
+ if (ancestor.code !== 0) throw new StateError('base-not-ancestor', `block ${id}: base ${block.base_sha.slice(0, 12)} is not an ancestor of ${head}`);
185
+ const diff = await git(['git', 'diff', '--no-renames', '--name-only', block.base_sha, newBase], cwd);
186
+ const conflicts = diff.stdout.split('\n').filter((file) => file.length > 0 && findOverlap(block.owned_files, [file]) !== null);
187
+ if (conflicts.length > 0) throw new StateError('owned-conflict', `block ${id}: commits since its base changed owned files: ${conflicts.join(', ')}`);
188
+ const moved = { old_base: block.base_sha, new_base: newBase };
189
+ block.base_sha = newBase;
190
+ return { result: moved, rows: [{ event: 'block.rebase', block: id, ...moved }] };
191
+ });
192
+ }
193
+
194
+ /**
195
+ * `block claim` — assign one EXACT path (an orphan, or any unowned path) to `id`; refused for a
196
+ * glob, and when another open block owns it.
197
+ * @param {{runId: string, id: string, file: string, writeRow: WriteRow}} opts
198
+ */
199
+ export async function claimPath({ runId, id, file, writeRow }) {
200
+ assertOwned([file]);
201
+ if (/[*?{]/.test(file)) throw new StateError('bad-owned', `block claim takes one exact path, not a glob (${JSON.stringify(file)})`);
202
+ await mutateRun(runId, id, writeRow, (record, block) => {
203
+ for (const [otherId, other] of Object.entries(activeBlocks(record))) {
204
+ if (otherId !== id && findOverlap([file], other.owned_files)) throw new StateError('overlap', `${file} is owned by open block ${otherId}`);
205
+ }
206
+ if (!block.owned_files.includes(file)) block.owned_files.push(file);
207
+ record.orphans = record.orphans.filter((/** @type {string} */ o) => o !== file);
208
+ return { result: undefined, rows: [{ event: 'block.claim', block: id, path: file }] };
209
+ });
210
+ }
211
+
212
+ /**
213
+ * The scope row for `id` against the tree's file set; NEW orphans are written to the registry
214
+ * and the ledger (`block.orphan`), so every active gate sees them.
215
+ * @param {{runId: string, id: string, files: string[], writeRow: WriteRow}} opts
216
+ * @returns {Promise<{ok: boolean, scope: string[], orphans: string[]}>}
217
+ */
218
+ export async function checkScope({ runId, id, files, writeRow }) {
219
+ return mutateRun(runId, id, writeRow, (record) => {
220
+ const gate = scopeGate(activeBlocks(record), id, files);
221
+ const fresh = gate.orphans.filter((o) => !record.orphans.includes(o));
222
+ record.orphans = [...record.orphans, ...fresh];
223
+ return { result: gate, rows: fresh.map((orphan) => ({ event: 'block.orphan', path: orphan })) };
224
+ });
225
+ }
226
+
227
+ /** @param {{runId: string, id: string, reason: string, writeRow: WriteRow, event?: string}} opts */
228
+ export async function stopBlock({ runId, id, reason, writeRow, event = 'block.stop' }) {
229
+ await mutateRun(runId, id, writeRow, (_record, block) => {
230
+ block.status = 'stopped';
231
+ block.stop_reason = reason;
232
+ return { result: undefined, rows: [{ event, block: id, reason }] };
233
+ });
234
+ }
235
+
236
+ /**
237
+ * B8's rows of the block gate, in order: worker pin ⇒ `worker.replaced`/`worker.down` (block
238
+ * stopped); the block's signed `dispatch` row must exist and verify, and every other
239
+ * gate-relevant row of the block must verify ⇒ else `ledger.tamper` (block stopped); then
240
+ * `extraChecks`; finally, UNDER the run lock, orphans in the registry ⇒ refused (block stays
241
+ * open), else closed.
242
+ * @param {{
243
+ * runId: string, id: string, rows: Record<string, any>[], writeRow: WriteRow,
244
+ * livePid?: number, probe?: import('./run.mjs').StartTimeProbe,
245
+ * extraChecks?: Array<() => Promise<{ok: boolean, reason?: string}>>,
246
+ * }} opts
247
+ * @returns {Promise<{ok: boolean, status: 'closed' | 'stopped' | 'open', reason?: string, event?: string}>}
248
+ */
249
+ export async function closeBlock({ runId, id, rows, writeRow, livePid, probe, extraChecks = [] }) {
250
+ const record = await readRun(runId);
251
+ if (record.blocks[id]?.status !== 'open') throw new StateError('no-block', `block ${id} is not open in run ${runId}`);
252
+
253
+ const pin = await checkWorkerPin(record, { livePid, probe });
254
+ if (!pin.ok) {
255
+ await stopBlock({ runId, id, reason: pin.reason, writeRow, event: pin.event });
256
+ return { ok: false, status: 'stopped', event: pin.event, reason: pin.reason };
257
+ }
258
+
259
+ const key = await loadKey(runId);
260
+ const relevant = rows.filter((row) => row.block === id && GATE_EVENTS.includes(row.event) && (row.run === undefined || row.run === runId));
261
+ const checked = relevant.map((row) => ({ row, result: verifyRow(row, key) }));
262
+ const bad = checked.filter((x) => !x.result.ok);
263
+ const dispatchOk = checked.some((x) => x.row.event === 'dispatch' && x.result.ok);
264
+ if (bad.length > 0 || !dispatchOk) {
265
+ const reasons = bad.map((x) => `${x.row.event}: ${x.result.reason}`);
266
+ if (!dispatchOk) reasons.push('no verified dispatch row');
267
+ const reason = reasons.join('; ');
268
+ await stopBlock({ runId, id, reason, writeRow, event: 'ledger.tamper' });
269
+ return { ok: false, status: 'stopped', event: 'ledger.tamper', reason };
270
+ }
271
+
272
+ for (const check of extraChecks) {
273
+ const result = await check();
274
+ if (!result.ok) return { ok: false, status: 'open', reason: result.reason ?? 'check failed' };
275
+ }
276
+
277
+ /** @typedef {{ok: boolean, status: 'closed' | 'stopped' | 'open', reason?: string, event?: string}} CloseResult */
278
+ return mutateRun(runId, id, writeRow, (current, block) => {
279
+ if (current.orphans.length > 0) {
280
+ /** @type {CloseResult} */
281
+ const refused = { ok: false, status: 'open', reason: `orphans: ${current.orphans.join(', ')}` };
282
+ return { result: refused, rows: [] };
283
+ }
284
+ block.status = 'closed';
285
+ /** @type {CloseResult} */
286
+ const closed = { ok: true, status: 'closed' };
287
+ return { result: closed, rows: [{ event: 'block.close', block: id, status: 'complete' }] };
288
+ });
289
+ }
@@ -0,0 +1,67 @@
1
+ /**
2
+ * Flag parsing shared by the `run` and `block` verbs. Strict: every flag must be declared in the
3
+ * spec — `values` (`--flag value` / `--flag=value`, once), `booleans` (no value), or `multi`
4
+ * (every following token up to the next `--flag`, seeded by an inline `--flag=value`). An unknown
5
+ * flag, a bare `--`, a repeated single-value flag, or a value on a boolean is a usage error, so a
6
+ * typo never swallows the next token silently. Positionals are what is left.
7
+ */
8
+
9
+ import { StateError } from './paths.mjs';
10
+
11
+ /**
12
+ * @param {string[]} args
13
+ * @param {{values?: string[], booleans?: string[], multi?: string[]}} [spec]
14
+ * @returns {{flags: Record<string, string | string[] | true>, positionals: string[]}}
15
+ * @throws {StateError} `usage`
16
+ */
17
+ export function parseFlags(args, { values = [], booleans = [], multi = [] } = {}) {
18
+ /** @type {Record<string, string | string[] | true>} */
19
+ const flags = {};
20
+ const positionals = [];
21
+ for (let i = 0; i < args.length; i += 1) {
22
+ const arg = args[i];
23
+ if (!arg.startsWith('--')) {
24
+ positionals.push(arg);
25
+ continue;
26
+ }
27
+ const eq = arg.indexOf('=');
28
+ const name = arg.slice(2, eq < 0 ? undefined : eq);
29
+ const inline = eq < 0 ? undefined : arg.slice(eq + 1);
30
+ if (!booleans.includes(name) && !multi.includes(name) && !values.includes(name)) {
31
+ throw new StateError('usage', `unknown flag ${JSON.stringify(arg)}`);
32
+ }
33
+ if (Object.hasOwn(flags, name) && !multi.includes(name)) throw new StateError('usage', `--${name} given more than once`);
34
+ if (booleans.includes(name)) {
35
+ if (inline !== undefined) throw new StateError('usage', `--${name} takes no value`);
36
+ flags[name] = true;
37
+ } else if (multi.includes(name)) {
38
+ const collected = Array.isArray(flags[name]) ? /** @type {string[]} */ (flags[name]) : [];
39
+ if (inline !== undefined && inline.length > 0) collected.push(inline);
40
+ while (i + 1 < args.length && !args[i + 1].startsWith('--')) collected.push(args[(i += 1)]);
41
+ if (collected.length === 0) throw new StateError('usage', `--${name} needs at least one value`);
42
+ flags[name] = collected;
43
+ } else if (inline !== undefined) {
44
+ if (inline.length === 0) throw new StateError('usage', `--${name} needs a value`);
45
+ flags[name] = inline;
46
+ } else {
47
+ const next = args[i + 1];
48
+ if (next === undefined || next.startsWith('--')) throw new StateError('usage', `--${name} needs a value`);
49
+ flags[name] = next;
50
+ i += 1;
51
+ }
52
+ }
53
+ return { flags, positionals };
54
+ }
55
+
56
+ /**
57
+ * A strictly positive safe integer written without sign or leading zeros.
58
+ * @param {string | string[] | true | undefined} raw @param {string} name
59
+ * @returns {number | undefined}
60
+ * @throws {StateError} `usage`
61
+ */
62
+ export function intFlag(raw, name) {
63
+ if (raw === undefined) return undefined;
64
+ const value = typeof raw === 'string' && /^[1-9]\d*$/.test(raw) ? Number(raw) : Number.NaN;
65
+ if (!Number.isSafeInteger(value)) throw new StateError('usage', `--${name} must be a positive integer`);
66
+ return value;
67
+ }
@@ -0,0 +1,92 @@
1
+ /**
2
+ * Where run state lives (plan §1.2, §4.9, §8.6) and the one error type the state verbs throw.
3
+ *
4
+ * - `~/.code-forge/runs/<run>.json` — the AUTHORITATIVE run record (registry + worker pin),
5
+ * outside every workspace; `~/.code-forge/runs/<run>.key` — the per-run HMAC key, mode 0600.
6
+ * - `<workspace>/.code-forge/runs/<run>.json` — an informational mirror, minus the worker pin.
7
+ *
8
+ * `os.homedir()` re-reads `$HOME` on every call (POSIX), so a test that points `HOME` at a temp
9
+ * dir never touches a real `~/.code-forge/`.
10
+ */
11
+
12
+ import { randomBytes } from 'node:crypto';
13
+ import { realpathSync } from 'node:fs';
14
+ import os from 'node:os';
15
+ import path from 'node:path';
16
+
17
+ /** A refusal with a stable machine-readable `code` (the CLI prints the message, exits 1). */
18
+ export class StateError extends Error {
19
+ /** @param {string} code @param {string} message */
20
+ constructor(code, message) {
21
+ super(message);
22
+ this.name = 'StateError';
23
+ this.code = code;
24
+ }
25
+ }
26
+
27
+ const RUN_ID_PATTERN = /^[a-z0-9][a-z0-9-]{0,63}$/;
28
+
29
+ /**
30
+ * @param {unknown} runId
31
+ * @returns {string}
32
+ * @throws {StateError} `bad-run-id` — a run id must never resolve outside the runs directory.
33
+ */
34
+ export function assertRunId(runId) {
35
+ if (typeof runId !== 'string' || !RUN_ID_PATTERN.test(runId)) {
36
+ throw new StateError('bad-run-id', `run id must match ${RUN_ID_PATTERN}`);
37
+ }
38
+ return runId;
39
+ }
40
+
41
+ /** @param {Date} [now] @returns {string} e.g. `r-20260924-201500-3fa9c1` */
42
+ export function newRunId(now = new Date()) {
43
+ const stamp = now.toISOString().replace(/[-:]/g, '').replace('T', '-').slice(0, 15);
44
+ return `r-${stamp}-${randomBytes(3).toString('hex')}`;
45
+ }
46
+
47
+ /** @returns {string} */
48
+ export function runsDir() {
49
+ return path.join(os.homedir(), '.code-forge', 'runs');
50
+ }
51
+
52
+ /** @param {string} runId @returns {string} */
53
+ export function runRecordPath(runId) {
54
+ return path.join(runsDir(), `${assertRunId(runId)}.json`);
55
+ }
56
+
57
+ /** @param {string} runId @returns {string} */
58
+ export function runKeyPath(runId) {
59
+ return path.join(runsDir(), `${assertRunId(runId)}.key`);
60
+ }
61
+
62
+ /** @param {string} p @returns {string} the real path when it exists, else the resolved one */
63
+ function canonical(p) {
64
+ try {
65
+ return realpathSync(p);
66
+ } catch {
67
+ return path.resolve(p);
68
+ }
69
+ }
70
+
71
+ /**
72
+ * The run record must live OUTSIDE the workspace (C4). A workspace that is `$HOME` or one of its
73
+ * ancestors would hold `~/.code-forge/runs/` inside it — and with workspace === `$HOME` the
74
+ * mirror would overwrite the authoritative record (dropping the worker pin).
75
+ * @param {string} workspace
76
+ * @returns {string} the canonical workspace path
77
+ * @throws {StateError} `workspace-contains-runs-dir`
78
+ */
79
+ export function assertWorkspaceOutsideRuns(workspace) {
80
+ const ws = canonical(workspace);
81
+ const rel = path.relative(ws, path.join(canonical(os.homedir()), '.code-forge', 'runs'));
82
+ if (!rel.startsWith('..') && !path.isAbsolute(rel)) {
83
+ throw new StateError('workspace-contains-runs-dir', `the workspace ${ws} contains ${runsDir()} — run code-forge from a project directory, not $HOME or above`);
84
+ }
85
+ return ws;
86
+ }
87
+
88
+ /** @param {string} workspace @param {string} runId @returns {string} */
89
+ export function mirrorPath(workspace, runId) {
90
+ assertWorkspaceOutsideRuns(workspace);
91
+ return path.join(workspace, '.code-forge', 'runs', `${assertRunId(runId)}.json`);
92
+ }
@@ -0,0 +1,189 @@
1
+ /**
2
+ * Shared-tree scope maths for parallel blocks in one working tree (plan §4.7, O2). Pure — the
3
+ * run record (`run.mjs`) stores the registry, the caller supplies the file set (B5 computes it:
4
+ * tracked changes ∪ untracked).
5
+ *
6
+ * Owned entries are exact paths or globs in a deliberately SMALL syntax, repo-relative, POSIX:
7
+ * `*` (any run of characters inside one segment), `?` (one character inside a segment), `**` (a
8
+ * whole segment matching zero or more segments) and `{a,b}` (expanded before anything else).
9
+ * Inside a GLOB, character classes and extglobs (`[` `]` `(` `)` `!` `+` `@`) are REFUSED by
10
+ * `assertOwned`, so no two readers can disagree on what an entry covers; in an EXACT path
11
+ * (no `*`, `?`, `{`) they are ordinary characters (`pages/[id].vue`) matched literally. The matcher is this module's own
12
+ * (`globMatch`), not `path.matchesGlob`, which Node still marks experimental. Dot-files are
13
+ * matched like any other name (fail closed for overlap).
14
+ *
15
+ * Overlap between two GLOBS cannot be decided in general, so it FAILS CLOSED: two globs are
16
+ * treated as overlapping when one's static directory prefix (the segments before the first glob
17
+ * character) is a segment-prefix of the other's — `test/fixtures/repos/two-blocks/**` and
18
+ * `test/fixtures/repos/node/**` are disjoint, `src/**` and `src/cli/*.mjs` overlap, and so do
19
+ * `src/*.mjs` and `src/cli/*.mjs` (they cannot share a path, but overlap is not ruled out by
20
+ * the prefix rule — the owner resolves it by naming exact paths).
21
+ */
22
+
23
+ import { StateError } from './paths.mjs';
24
+
25
+ const GLOB_CHARS = /[*?]/;
26
+ /** Glob syntax this matcher does NOT implement — refused inside a glob entry only. */
27
+ const UNSUPPORTED_GLOB_CHARS = /[[\]()!+@]/;
28
+
29
+ /** @param {string} segment @returns {RegExp} */
30
+ function segmentRegExp(segment) {
31
+ const body = segment
32
+ .split('')
33
+ .map((c) => (c === '*' ? '[^/]*' : c === '?' ? '[^/]' : c.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')))
34
+ .join('');
35
+ return new RegExp(`^${body}$`);
36
+ }
37
+
38
+ /**
39
+ * Does repo-relative `file` match brace-free `pattern` (`*`, `?`, `**` — see the module doc)?
40
+ * @param {string} file @param {string} pattern
41
+ * @returns {boolean}
42
+ */
43
+ export function globMatch(file, pattern) {
44
+ const f = file.split('/');
45
+ const p = pattern.split('/');
46
+ /** @type {(i: number, j: number) => boolean} */
47
+ const match = (i, j) => {
48
+ if (j === p.length) return i === f.length;
49
+ if (p[j] === '**') {
50
+ for (let k = i; k <= f.length; k += 1) if (match(k, j + 1)) return true;
51
+ return false;
52
+ }
53
+ return i < f.length && segmentRegExp(p[j]).test(f[i]) && match(i + 1, j + 1);
54
+ };
55
+ return match(0, 0);
56
+ }
57
+
58
+ /**
59
+ * @param {string} pattern
60
+ * @returns {string[]} every alternative of every `{a,b}` group (nested groups included).
61
+ */
62
+ export function expandBraces(pattern) {
63
+ const open = pattern.indexOf('{');
64
+ if (open < 0) return [pattern];
65
+ let depth = 0;
66
+ let close = -1;
67
+ const commas = [];
68
+ for (let i = open; i < pattern.length; i += 1) {
69
+ if (pattern[i] === '{') depth += 1;
70
+ else if (pattern[i] === '}' && --depth === 0) {
71
+ close = i;
72
+ break;
73
+ } else if (pattern[i] === ',' && depth === 1) commas.push(i);
74
+ }
75
+ if (close < 0) return [pattern];
76
+ const bounds = [open, ...commas, close];
77
+ const head = pattern.slice(0, open);
78
+ const tail = pattern.slice(close + 1);
79
+ return bounds.slice(0, -1).flatMap((start, i) => expandBraces(head + pattern.slice(start + 1, bounds[i + 1]) + tail));
80
+ }
81
+
82
+ /**
83
+ * @param {unknown} owned
84
+ * @returns {string[]}
85
+ * An EXACT entry (no `*`, `?` or `{`) may contain `[ ] ( ) ! + @ }` — real routes such as
86
+ * `pages/[id].vue`, `app/(group)/page.tsx`, `@types/x.d.ts` — and is matched literally by
87
+ * string equality. A GLOB entry may not: there those characters would be character-class or
88
+ * extglob syntax this matcher does not implement, so it is refused with that reason rather than
89
+ * silently read one way by us and another way by git or a shell.
90
+ * @throws {StateError} `bad-owned` — empty, absolute, `.`/`..`/empty segments (checked on every
91
+ * brace alternative), backslashes, unbalanced braces, or unsupported glob syntax in a glob.
92
+ */
93
+ export function assertOwned(owned) {
94
+ if (!Array.isArray(owned) || owned.length === 0) throw new StateError('bad-owned', 'owned files: at least one path is required');
95
+ for (const entry of owned) {
96
+ /** @param {string} why */
97
+ const refuse = (why) => {
98
+ throw new StateError('bad-owned', `owned files: invalid path ${JSON.stringify(entry)}: ${why}`);
99
+ };
100
+ if (typeof entry !== 'string' || entry.length === 0) refuse('must be a non-empty string');
101
+ if (entry.includes('\\')) refuse('backslashes are not allowed (POSIX paths only)');
102
+ const isGlob = /[*?{]/.test(entry);
103
+ if (isGlob && UNSUPPORTED_GLOB_CHARS.test(entry)) {
104
+ refuse('a glob may not contain [ ] ( ) ! + @ — character classes and extglobs are not supported; name the files exactly or use only *, ?, ** and {a,b}');
105
+ }
106
+ for (const alt of expandBraces(entry)) {
107
+ if (alt.startsWith('/')) refuse('must be repo-relative, not absolute');
108
+ if (isGlob && /[{}]/.test(alt)) refuse('unbalanced braces');
109
+ if (alt.split('/').some((segment) => segment === '..' || segment === '.' || segment === '')) refuse('no ".", ".." or empty segments');
110
+ }
111
+ }
112
+ return owned;
113
+ }
114
+
115
+ /** @param {string} p @returns {string[]} segments before the first glob character */
116
+ function staticSegments(p) {
117
+ const segments = p.split('/');
118
+ const firstGlob = segments.findIndex((s) => GLOB_CHARS.test(s));
119
+ return firstGlob < 0 ? segments : segments.slice(0, firstGlob);
120
+ }
121
+
122
+ /** @param {string[]} a @param {string[]} b */
123
+ const segmentPrefix = (a, b) => a.length <= b.length && a.every((s, i) => s === b[i]);
124
+
125
+ /**
126
+ * @param {string} a @param {string} b - brace-free patterns
127
+ * @returns {boolean}
128
+ */
129
+ function patternsOverlap(a, b) {
130
+ const aGlob = GLOB_CHARS.test(a);
131
+ const bGlob = GLOB_CHARS.test(b);
132
+ if (!aGlob && !bGlob) return a === b;
133
+ if (!aGlob) return globMatch(a, b);
134
+ if (!bGlob) return globMatch(b, a);
135
+ const sa = staticSegments(a);
136
+ const sb = staticSegments(b);
137
+ return segmentPrefix(sa, sb) || segmentPrefix(sb, sa);
138
+ }
139
+
140
+ /**
141
+ * @param {ReadonlyArray<string>} ownedA @param {ReadonlyArray<string>} ownedB
142
+ * @returns {[string, string] | null} the first overlapping pair (original spellings), or null.
143
+ */
144
+ export function findOverlap(ownedA, ownedB) {
145
+ for (const a of ownedA) {
146
+ for (const b of ownedB) {
147
+ if (expandBraces(a).some((x) => expandBraces(b).some((y) => patternsOverlap(x, y)))) return [a, b];
148
+ }
149
+ }
150
+ return null;
151
+ }
152
+
153
+ /**
154
+ * @param {ReadonlyArray<string>} owned @param {string} file
155
+ * @returns {boolean}
156
+ */
157
+ export function ownsFile(owned, file) {
158
+ return owned.some((entry) => expandBraces(entry).some((p) => p === file || (GLOB_CHARS.test(p) && globMatch(file, p))));
159
+ }
160
+
161
+ /**
162
+ * @typedef {{owned_files: string[], status?: string}} RegistryBlock
163
+ * @param {Record<string, RegistryBlock>} blocks - the ACTIVE blocks only (status `open`)
164
+ * @param {ReadonlyArray<string>} files - the shared tree's changed + untracked files
165
+ * @returns {{scopes: Record<string, string[]>, orphans: string[]}}
166
+ */
167
+ export function computeScopes(blocks, files) {
168
+ /** @type {Record<string, string[]>} */
169
+ const scopes = Object.fromEntries(Object.keys(blocks).map((id) => [id, []]));
170
+ const orphans = [];
171
+ for (const file of [...new Set(files)].sort()) {
172
+ const owners = Object.keys(blocks).filter((id) => ownsFile(blocks[id].owned_files, file));
173
+ if (owners.length === 0) orphans.push(file);
174
+ for (const id of owners) scopes[id].push(file);
175
+ }
176
+ return { scopes, orphans };
177
+ }
178
+
179
+ /**
180
+ * The scope row of block `blockId`'s gate: its files are what it owns; ANY orphan in the tree
181
+ * refuses EVERY active block's gate until it is claimed (`block claim`) or deleted.
182
+ * @param {Record<string, RegistryBlock>} blocks @param {string} blockId @param {ReadonlyArray<string>} files
183
+ * @returns {{ok: boolean, scope: string[], orphans: string[]}}
184
+ */
185
+ export function scopeGate(blocks, blockId, files) {
186
+ if (!blocks[blockId]) throw new StateError('no-block', `block ${blockId} is not active`);
187
+ const { scopes, orphans } = computeScopes(blocks, files);
188
+ return { ok: orphans.length === 0, scope: scopes[blockId], orphans };
189
+ }