@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,316 @@
1
+ /**
2
+ * `code-forge proof tier|export|lock|unlock|baseline|restore` (plan §7, §4.7). Prints one JSON
3
+ * object (redacted) per call. Exit codes: 0 done, 1 refused (`proof.busy`, not the holder, bad
4
+ * ref…), 2 usage or a subcommand that has not landed.
5
+ *
6
+ * proof tier --file <path> --risk <0-3> [--security] [--cwd <dir>]
7
+ * proof export <block> --run <r> [--remove]
8
+ * proof lock <block> --run <r>
9
+ * proof unlock <block> --run <r>
10
+ * proof baseline (held on Q16 — block B10c)
11
+ * proof restore <block> --run <r> (the red→green journal, B10b: restores the block's export
12
+ * and the workspace; refuses with exit 1 when neither holds a journal)
13
+ * proof red-green <block> --run <r> --test <file[::case]> [--mechanism revert|assertion-deletion]
14
+ * (B17: one red→green proof through `runRedGreen`, measured in a
15
+ * freshly built export — or in the workspace under `proof lock`,
16
+ * which this block must hold; exit 0 proven, 1 not proven or refused).
17
+ * The signed row carries `covers` (B20): the reverted sources, or
18
+ * the test file for `assertion-deletion`.
19
+ *
20
+ * `export` reads `proof.export.{link_dirs,copy_untracked}` from the workspace's `.code-forge.yml`
21
+ * (defaults §1.3) and writes a signed `proof` row `isolation: export`; `lock`/`unlock` write
22
+ * `isolation: lock` rows (B6 writer, B8 signer).
23
+ */
24
+
25
+ import { lstat } from 'node:fs/promises';
26
+ import path from 'node:path';
27
+ import { DEFAULT_CONFIG_FILENAME, loadProjectConfig } from '../config/load.mjs';
28
+ import { validateConfig } from '../config/validate.mjs';
29
+ import { appendRow } from '../ledger/write.mjs';
30
+ import { parseFlags } from '../state/cli-args.mjs';
31
+ import { StateError } from '../state/paths.mjs';
32
+ import { readRun } from '../state/run.mjs';
33
+ import { redactJSON, writeSafe } from '../util/redact.mjs';
34
+ import { DEFAULT_COPY_UNTRACKED, DEFAULT_LINK_DIRS, buildExport, exportDirFor, recordProof, removeExport } from '../proof/export.mjs';
35
+ import { acquireProofLock, lockHolder, releaseProofLock } from '../proof/lock.mjs';
36
+ import { MECHANISMS, readJournal, restoreJournal, runRedGreen } from '../proof/red-green.mjs';
37
+ import { tierFor } from '../proof/tiers.mjs';
38
+ import { blockFileSet, repoRelativePath } from '../review/gate-check.mjs';
39
+ import { findOverlap } from '../state/registry.mjs';
40
+
41
+ const USAGE =
42
+ 'usage: code-forge proof tier --file <path> --risk <0-3> [--security] [--cwd <dir>]\n' +
43
+ ' code-forge proof export <block> --run <r> [--remove] · proof lock|unlock <block> --run <r>\n' +
44
+ ' code-forge proof restore <block> --run <r>\n' +
45
+ ' code-forge proof red-green <block> --run <r> --test <file[::case]> [--mechanism revert|assertion-deletion]\n';
46
+
47
+ /** A test file by path convention: never reverted as a "source" by the `revert` mechanism. */
48
+ const TEST_PATH = /(?:^|\/)(?:test|tests|__tests__|spec)\/|\.(?:test|spec)\.[cm]?[jt]sx?$|Test\.php$/;
49
+
50
+ /** The JavaScript-family test files the filtered command below knows how to run. */
51
+ const NODE_TEST_FILE = /\.(?:mjs|cjs|js)$/;
52
+
53
+ /**
54
+ * `--test` as a repo-relative POSIX path: `./` segments dropped, never absolute, never escaping
55
+ * the repository with `..`, nothing under `.code-forge/`.
56
+ * @param {string} raw @returns {string}
57
+ * @throws {StateError} `usage`
58
+ */
59
+ function normalizeTestPath(raw) {
60
+ if (raw.length === 0 || path.isAbsolute(raw) || raw.includes('\\') || raw.includes('\0')) throw new StateError('usage', '--test must be a repo-relative path');
61
+ const normalized = path.posix.normalize(raw);
62
+ if (normalized === '.' || normalized === '..' || normalized.startsWith('../')) throw new StateError('usage', '--test must stay inside the repository (no .. escape)');
63
+ if (normalized === '.code-forge' || normalized.startsWith('.code-forge/')) throw new StateError('usage', '--test cannot be under .code-forge/');
64
+ return normalized;
65
+ }
66
+
67
+ /**
68
+ * @param {string} dir - the work dir the test runs in @param {string} file - normalized, repo-relative
69
+ * @throws {StateError} `usage` when `file` is not a regular file under `dir`
70
+ */
71
+ async function assertTestFile(dir, file) {
72
+ const st = await lstat(path.join(dir, file)).catch(() => null);
73
+ if (!st?.isFile()) throw new StateError('usage', `--test ${file} is not a file in ${dir}`);
74
+ }
75
+
76
+ /**
77
+ * The test command filtered to one test: `node --test [--test-name-pattern ^<case>$] <file>`, run
78
+ * with the Node that runs this CLI. Other stacks (Pest, Vitest…) have no filtered command here yet.
79
+ * @param {string} file @param {string | undefined} testCase
80
+ * @returns {string[]}
81
+ * @throws {StateError} `unsupported-test`
82
+ */
83
+ function filteredTestArgv(file, testCase) {
84
+ if (!NODE_TEST_FILE.test(file)) throw new StateError('unsupported-test', 'only node:test files (.mjs, .cjs, .js) have a filtered test command in this version');
85
+ const pattern = testCase === undefined ? [] : ['--test-name-pattern', `^${testCase.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}$`];
86
+ return [process.execPath, '--test', ...pattern, file];
87
+ }
88
+
89
+ const NOT_LANDED = {
90
+ baseline: 'proof baseline: held on Q16 (block B10c) — the gate enforces red→green only',
91
+ };
92
+
93
+ /** @param {Record<string, any>} config @returns {string[]} */
94
+ const highPathsOf = (config) => (Array.isArray(config?.proof?.tiers?.high?.paths) ? config.proof.tiers.high.paths : []);
95
+
96
+ /**
97
+ * The project config. `{}` (the §1.3 defaults apply) ONLY when no `.code-forge.yml` exists; a file
98
+ * that exists but does not load or validate is refused — a typo must never silently drop
99
+ * `proof.tiers.high.paths` (a tier downgrade), the `proof.isolation: lock` guard or the export
100
+ * lists. The message names the problem kind and key paths only, never values.
101
+ * @param {string} cwd
102
+ * @returns {Promise<Record<string, any>>}
103
+ * @throws {StateError} `bad-config`
104
+ */
105
+ async function configAt(cwd) {
106
+ const loaded = await loadProjectConfig(cwd);
107
+ if (!loaded.ok && loaded.error === 'not-found') return {};
108
+ if (!loaded.ok || !loaded.config) {
109
+ throw new StateError('bad-config', `${DEFAULT_CONFIG_FILENAME} does not load (${loaded.error ?? 'no config'}) — run code-forge validate`);
110
+ }
111
+ const result = validateConfig(loaded.config);
112
+ if (!result.valid) {
113
+ const where = [...new Set(result.errors.map((e) => (typeof e.path === 'string' ? e.path : e.rule)))].join(', ');
114
+ throw new StateError('bad-config', `${DEFAULT_CONFIG_FILENAME} is invalid (${result.errors.length} error(s) at ${where}) — run code-forge validate`);
115
+ }
116
+ return loaded.config;
117
+ }
118
+
119
+ /**
120
+ * Build the block's measurement export and write its signed `proof` row (`step: export`).
121
+ * @param {{runId: string, record: Record<string, any>, blockId: string, block: Record<string, any>, config: Record<string, any>, writeRow: (row: Record<string, any>) => Promise<unknown>}} opts
122
+ */
123
+ async function exportAndRecord({ runId, record, blockId, block, config, writeRow }) {
124
+ const exp = await buildExport({
125
+ cwd: record.workspace,
126
+ blockId,
127
+ baseSha: block.base_sha,
128
+ owned: block.owned_files,
129
+ linkDirs: config?.proof?.export?.link_dirs ?? DEFAULT_LINK_DIRS,
130
+ copyUntracked: config?.proof?.export?.copy_untracked ?? DEFAULT_COPY_UNTRACKED,
131
+ });
132
+ await recordProof({
133
+ runId,
134
+ writeRow,
135
+ row: { block: blockId, isolation: 'export', step: 'export', base_sha: exp.base, restored: exp.restored, untracked: exp.untracked },
136
+ });
137
+ return exp;
138
+ }
139
+
140
+ /**
141
+ * @param {string[]} args
142
+ * @param {{stdout?: {write: (s: string) => unknown}, stderr?: {write: (s: string) => unknown}}} [deps]
143
+ * @returns {Promise<number>}
144
+ */
145
+ export async function runProof(args, deps = {}) {
146
+ const { stdout = process.stdout, stderr = process.stderr } = deps;
147
+ const out = (/** @type {object} */ value) => writeSafe(stdout, `${redactJSON(value)}\n`);
148
+ const err = (/** @type {string} */ s) => writeSafe(stderr, s);
149
+ const [sub, ...rest] = args;
150
+
151
+ if (typeof sub === 'string' && Object.hasOwn(NOT_LANDED, sub)) {
152
+ err(`${NOT_LANDED[/** @type {'baseline'} */ (sub)]}\n`);
153
+ return 2;
154
+ }
155
+ try {
156
+ if (sub === 'tier') {
157
+ const { flags, positionals } = parseFlags(rest, { values: ['file', 'risk', 'cwd'], booleans: ['security'] });
158
+ if (positionals.length > 0 || typeof flags.file !== 'string' || typeof flags.risk !== 'string') {
159
+ throw new StateError('usage', 'proof tier needs --file and --risk');
160
+ }
161
+ if (!/^[0-3]$/.test(flags.risk)) throw new StateError('usage', '--risk must be 0, 1, 2 or 3');
162
+ const config = await configAt(typeof flags.cwd === 'string' ? flags.cwd : process.cwd());
163
+ const result = tierFor({ file: flags.file, risk: Number(flags.risk), securitySensitive: flags.security === true, highPaths: highPathsOf(config) });
164
+ out({ file: flags.file, ...result });
165
+ return 0;
166
+ }
167
+
168
+ if (sub === 'restore') {
169
+ const { flags, positionals } = parseFlags(rest, { values: ['run'] });
170
+ const [blockId, extra] = positionals;
171
+ if (!blockId || extra !== undefined || typeof flags.run !== 'string') throw new StateError('usage', 'proof restore needs one block id and --run');
172
+ const record = await readRun(flags.run);
173
+ // The runner journals in the directory it measured in: the block's export, or the
174
+ // workspace itself under the proof lock.
175
+ const dirs = [exportDirFor(record.workspace, blockId), record.workspace];
176
+ const restored = [];
177
+ for (const workDir of dirs) {
178
+ if ((await readJournal(workDir)) === null) continue;
179
+ restored.push({ dir: workDir, files: (await restoreJournal({ workDir })).restored });
180
+ }
181
+ if (restored.length === 0) throw new StateError('no-journal', `no red→green journal for block ${blockId} — nothing to restore`);
182
+ out({ restored });
183
+ return 0;
184
+ }
185
+
186
+ if (sub === 'export' || sub === 'lock' || sub === 'unlock') {
187
+ const { flags, positionals } = parseFlags(rest, { values: ['run'], booleans: sub === 'export' ? ['remove'] : [] });
188
+ const [blockId, extra] = positionals;
189
+ if (!blockId || extra !== undefined || typeof flags.run !== 'string') throw new StateError('usage', `proof ${sub} needs one block id and --run`);
190
+ const runId = flags.run;
191
+ const record = await readRun(runId);
192
+ const writeRow = (/** @type {Record<string, any>} */ row) => appendRow(row, { slug: record.project });
193
+
194
+ if (sub === 'lock') {
195
+ out({ locked: await acquireProofLock({ runId, blockId, writeRow }) });
196
+ return 0;
197
+ }
198
+ if (sub === 'unlock') {
199
+ await releaseProofLock({ runId, blockId, writeRow });
200
+ out({ unlocked: blockId });
201
+ return 0;
202
+ }
203
+
204
+ const block = record.blocks?.[blockId];
205
+ if (block?.status !== 'open') throw new StateError('no-block', `block ${blockId} is not open in run ${runId}`);
206
+ if (flags.remove === true) {
207
+ await removeExport({ cwd: record.workspace, blockId });
208
+ out({ removed: blockId });
209
+ return 0;
210
+ }
211
+ const config = await configAt(record.workspace);
212
+ if (config?.proof?.isolation === 'lock') throw new StateError('isolation-lock', 'proof.isolation is lock — use proof lock instead of an export');
213
+ out(await exportAndRecord({ runId, record, blockId, block, config, writeRow }));
214
+ return 0;
215
+ }
216
+
217
+ if (sub === 'red-green') {
218
+ const { flags, positionals } = parseFlags(rest, { values: ['run', 'test', 'mechanism'] });
219
+ const [blockId, extra] = positionals;
220
+ if (!blockId || extra !== undefined || typeof flags.run !== 'string' || typeof flags.test !== 'string') {
221
+ throw new StateError('usage', 'proof red-green needs one block id, --run and --test <file[::case]>');
222
+ }
223
+ const mechanism = flags.mechanism;
224
+ if (mechanism !== undefined && !MECHANISMS.includes(/** @type {any} */ (mechanism))) throw new StateError('usage', `--mechanism must be one of ${MECHANISMS.join(', ')}`);
225
+ const sep = flags.test.indexOf('::');
226
+ const testCase = sep < 0 ? undefined : flags.test.slice(sep + 2);
227
+ if (testCase === '') throw new StateError('usage', '--test <file>::<case> needs a case name after ::');
228
+ const file = normalizeTestPath(sep < 0 ? flags.test : flags.test.slice(0, sep));
229
+ const argv = filteredTestArgv(file, testCase);
230
+ const runId = flags.run;
231
+ const record = await readRun(runId);
232
+ const block = record.blocks?.[blockId];
233
+ if (block?.status !== 'open') throw new StateError('no-block', `block ${blockId} is not open in run ${runId}`);
234
+ await assertTestFile(record.workspace, file);
235
+ const writeRow = (/** @type {Record<string, any>} */ row) => appendRow(row, { slug: record.project });
236
+ const config = await configAt(record.workspace);
237
+ const isolation = config?.proof?.isolation === 'lock' ? 'lock' : 'export';
238
+ if (isolation === 'lock' && (await lockHolder(runId)) !== blockId) {
239
+ throw new StateError('proof.busy', `proof.isolation is lock — run code-forge proof lock ${blockId} --run ${runId} first`);
240
+ }
241
+ // `revert` puts every changed non-test owned file back to base; `assertion-deletion` touches only the test.
242
+ const changed = await blockFileSet({
243
+ repoRoot: record.workspace,
244
+ base: block.base_sha,
245
+ owned: block.owned_files,
246
+ matchOwned: (f) => findOverlap(block.owned_files, [f]) !== null,
247
+ });
248
+ const sources = mechanism === 'assertion-deletion' ? [] : changed.map((c) => c.file).filter((f) => f !== file && !TEST_PATH.test(f));
249
+ if (mechanism === 'revert' && sources.length === 0) {
250
+ throw new StateError('usage', `block ${blockId} changed no non-test source file to revert — use --mechanism assertion-deletion`);
251
+ }
252
+ const exp = isolation === 'export' ? await exportAndRecord({ runId, record, blockId, block, config, writeRow }) : null;
253
+ if (exp) await assertTestFile(exp.dir, file); // the test must also be where it runs (owned, or tracked at base)
254
+ const result = await runRedGreen({
255
+ workDir: exp ? exp.dir : record.workspace,
256
+ repoDir: record.workspace,
257
+ base: block.base_sha,
258
+ argv,
259
+ test: { file, ...(testCase === undefined ? {} : { case: testCase }), characterization: mechanism === 'assertion-deletion' },
260
+ sources,
261
+ isolation,
262
+ print: (line) => err(`${line}\n`),
263
+ });
264
+ // B20: the signed row names what the proof covers — the block gate reads `covers` per
265
+ // high-tier file. `revert` covers the sources it put back to base; `assertion-deletion`
266
+ // covers only the test file itself (its `::case` label is never part of a path). Every entry
267
+ // is normalized exactly as the gate normalizes its changed files (`repoRelativePath`).
268
+ const covers = [...new Set((result.mechanism === 'revert' ? sources : [file]).map((f) => repoRelativePath(f)))].sort();
269
+ // A result with no red phase (refused before the tree was touched) is never proven: the row
270
+ // still lands, `proven: false` and `red_kind: null`, and the verb exits 1.
271
+ const red = result.red ?? null;
272
+ const proven = result.proven === true && red !== null;
273
+ await recordProof({
274
+ runId,
275
+ writeRow,
276
+ row: {
277
+ block: blockId,
278
+ isolation,
279
+ step: 'red-green',
280
+ test: result.label,
281
+ mechanism: result.mechanism,
282
+ red_kind: red?.red_kind ?? null,
283
+ red: red?.verdict ?? null,
284
+ green: result.green ?? null,
285
+ proven,
286
+ failed: red?.failed ?? null,
287
+ covers,
288
+ },
289
+ });
290
+ out({
291
+ block: blockId,
292
+ test: result.label,
293
+ mechanism: result.mechanism,
294
+ isolation,
295
+ red: red?.verdict ?? null,
296
+ red_kind: red?.red_kind ?? null,
297
+ green: result.green ?? null,
298
+ proven,
299
+ ...(exp ? { export: { dir: exp.dir, restored: exp.restored, untracked: exp.untracked } } : {}),
300
+ });
301
+ return proven ? 0 : 1;
302
+ }
303
+ } catch (thrown) {
304
+ const code = thrown instanceof StateError ? thrown.code : 'error';
305
+ err(`proof ${sub}: ${thrown?.message ?? String(thrown)}\n`);
306
+ return code === 'usage' ? 2 : 1;
307
+ }
308
+
309
+ err(USAGE);
310
+ return 2;
311
+ }
312
+
313
+ /** @param {string[]} args @returns {Promise<number>} */
314
+ export default async function proof(args) {
315
+ return runProof(args);
316
+ }
@@ -0,0 +1,76 @@
1
+ /**
2
+ * `code-forge remove [<harness>] [--scope project|global]` — deletes ONLY paths recorded in
3
+ * `~/.code-forge/installs.json` (plan §10.3 acceptance: "`remove` deletes only the 3 recorded
4
+ * paths and leaves a 4th unrecorded file"). With no arguments, every recorded install is removed;
5
+ * `<harness>` narrows to one harness id; `--scope` further narrows to `project` or `global`.
6
+ */
7
+
8
+ import os from 'node:os';
9
+ import { writeSafe } from '../util/redact.mjs';
10
+ import { installsPath, removeRecords } from '../install/link.mjs';
11
+
12
+ const USAGE = 'usage: code-forge remove [<harness>] [--scope project|global]\n';
13
+
14
+ /**
15
+ * Strict parse: `[<harness>] [--scope <project|global>]`, in that order. An unknown flag, a
16
+ * missing/invalid `--scope` value, a harness name that looks like a flag, or an extra argument is
17
+ * a usage error — never silently reinterpreted.
18
+ * @param {string[]} args
19
+ * @returns {{harness?: string, scope?: 'project'|'global'} | null}
20
+ */
21
+ export function parseRemoveArgs(args) {
22
+ if (args.length === 0) {
23
+ return {};
24
+ }
25
+ let harness;
26
+ let rest = args;
27
+ if (!rest[0].startsWith('-')) {
28
+ [harness, ...rest] = rest;
29
+ }
30
+ if (rest.length === 0) {
31
+ return { harness };
32
+ }
33
+ const [flag, value, ...extra] = rest;
34
+ if (flag !== '--scope' || (value !== 'project' && value !== 'global') || extra.length > 0) {
35
+ return null;
36
+ }
37
+ // `harness` is only included when it was actually parsed — an object with an explicit
38
+ // `harness: undefined` key is NOT deep-equal to one that omits the key altogether.
39
+ return harness === undefined ? { scope: value } : { harness, scope: value };
40
+ }
41
+
42
+ /**
43
+ * @param {string[]} args
44
+ * @param {object} deps
45
+ * @param {{write: (s: string) => unknown}} [deps.stdout]
46
+ * @param {{write: (s: string) => unknown}} [deps.stderr]
47
+ * @param {string} [deps.home]
48
+ * @returns {Promise<number>}
49
+ */
50
+ export async function runRemove(args, { stdout = process.stdout, stderr = process.stderr, home = os.homedir() } = {}) {
51
+ const out = (/** @type {string} */ s) => writeSafe(stdout, s);
52
+ const err = (/** @type {string} */ s) => writeSafe(stderr, s);
53
+
54
+ const parsed = parseRemoveArgs(args);
55
+ if (!parsed) {
56
+ err(USAGE);
57
+ return 2;
58
+ }
59
+
60
+ const removed = await removeRecords({ installsFile: installsPath(home), harness: parsed.harness, scope: parsed.scope });
61
+ for (const record of removed) {
62
+ out(`removed ${record.harness} (${record.scope}) ${record.target}\n`);
63
+ }
64
+ if (removed.length === 0) {
65
+ out('nothing to remove\n');
66
+ }
67
+ return 0;
68
+ }
69
+
70
+ /**
71
+ * @param {string[]} args
72
+ * @returns {Promise<number>}
73
+ */
74
+ export default async function remove(args) {
75
+ return runRemove(args, {});
76
+ }
@@ -0,0 +1,73 @@
1
+ /**
2
+ * `code-forge report [--slug <slug>] [--json] [--export <dir>]` (plan §6.2, R4). Terminal output
3
+ * only, plus an optional `--export <dir>` that writes the same 13 sections as JSON files.
4
+ */
5
+
6
+ import { writeSafe } from '../util/redact.mjs';
7
+ import { buildReport } from '../ledger/report.mjs';
8
+ import { exportReportSections } from '../ledger/export.mjs';
9
+ import { readAllRows } from '../ledger/write.mjs';
10
+
11
+ /**
12
+ * @param {string[]} args @param {string} flag @returns {string|undefined}
13
+ * `undefined` (not the next token) when the flag is absent, is the LAST token, or the "next
14
+ * token" starts with `--` — otherwise `report --slug --json` would read `--json` as the slug, and
15
+ * `report --slug x --export` (missing a directory) would silently print instead of exporting.
16
+ */
17
+ function flagValue(args, flag) {
18
+ const i = args.indexOf(flag);
19
+ if (i < 0) return undefined;
20
+ const next = args[i + 1];
21
+ return next !== undefined && !next.startsWith('--') ? next : undefined;
22
+ }
23
+
24
+ /**
25
+ * @param {string[]} args
26
+ * @param {{stdout?: {write: (s: string) => unknown}, stderr?: {write: (s: string) => unknown}}} [deps]
27
+ * @returns {Promise<number>}
28
+ */
29
+ export async function runReport(args, deps = {}) {
30
+ const { stdout = process.stdout, stderr = process.stderr } = deps;
31
+ const out = (/** @type {string} */ s) => writeSafe(stdout, s);
32
+ const err = (/** @type {string} */ s) => writeSafe(stderr, s);
33
+
34
+ const slug = flagValue(args, '--slug');
35
+ if (!slug) {
36
+ err('usage: code-forge report --slug <slug> [--json] [--export <dir>]\n');
37
+ return 2;
38
+ }
39
+
40
+ // `readAllRows`/`ledgerPath` (src/ledger/paths.mjs) already refuse a slug shaped like a path
41
+ // (anything outside `/^[a-z0-9][a-z0-9-]*$/` — including `..` and `/`) with a TypeError; the
42
+ // try/catch below turns that, and any other I/O failure, into a clean CLI message instead of a
43
+ // raw stack trace that skips the `writeSafe` redaction path.
44
+ try {
45
+ const rows = await readAllRows(slug);
46
+ const { sections } = buildReport(rows);
47
+
48
+ const exportDir = flagValue(args, '--export');
49
+ if (exportDir) {
50
+ const paths = await exportReportSections(exportDir, sections);
51
+ out(`exported ${paths.length} section file(s) to ${exportDir}\n`);
52
+ return 0;
53
+ }
54
+
55
+ if (args.includes('--json')) {
56
+ out(`${JSON.stringify(sections, null, 2)}\n`);
57
+ return 0;
58
+ }
59
+
60
+ for (const [name, data] of Object.entries(sections)) {
61
+ out(`== ${name} ==\n${JSON.stringify(data, null, 2)}\n`);
62
+ }
63
+ return 0;
64
+ } catch (err_) {
65
+ err(`report: ${err_?.message ?? String(err_)}\n`);
66
+ return 1;
67
+ }
68
+ }
69
+
70
+ /** @param {string[]} args @returns {Promise<number>} */
71
+ export default async function report(args) {
72
+ return runReport(args);
73
+ }
@@ -0,0 +1,79 @@
1
+ /**
2
+ * `code-forge resolve <L0|L1|L2|L3>` — prints what a level actually resolves to right now
3
+ * (plan §5.2, §10.3 B4 acceptance): `{provider, model, effort, fallback, cli}`, via B1's
4
+ * `resolveLevel` (the per-level `provider` override wins over the top-level `provider`, R5) plus
5
+ * this block's own `cliNameForProvider` (the CLI binary `engine: subprocess` would spawn).
6
+ *
7
+ * **Fix round 1 (isolated per-file review, all MINOR):**
8
+ * - The result is now written with `redact.writeSafe(process.stdout, ...)` — B0's documented
9
+ * "stdout path" for a machine-readable value that must NOT go through `log.info` (which prefixes
10
+ * every line with `[code-forge:info] `, defeating `jq`/pipe consumers of this verb's output).
11
+ * - `cli: cliNameForProvider(...) ?? null` used to still exit 0 for a provider with no CLI mapping
12
+ * (possible: `resolve` does not itself call `validateConfig`, unlike `validate.mjs`, so a config
13
+ * with an out-of-enum `provider` string can reach here). It now logs an error and exits 1
14
+ * instead of silently printing `"cli": null`.
15
+ * - `resolved.fallback` is never `undefined` — `resolveLevel` (B1, `known-ids.mjs`) always returns
16
+ * a (possibly empty) frozen array, so no `?? null`/`?? []` default was ever needed here; the
17
+ * test suite now locks in the exact 5-key contract for BOTH the with-fallback and
18
+ * without-fallback cases, closing the test gap the review actually found.
19
+ */
20
+
21
+ import { loadProjectConfig } from '../config/load.mjs';
22
+ import { resolveLevel } from '../config/known-ids.mjs';
23
+ import { cliNameForProvider } from '../engines/provider-cli.mjs';
24
+ import { maskSecretTokens } from '../config/secret-patterns.mjs';
25
+ import { writeSafe } from '../util/redact.mjs';
26
+ import * as log from '../util/log.mjs';
27
+
28
+ const VALID_LEVELS = Object.freeze(['L0', 'L1', 'L2', 'L3']);
29
+
30
+ /**
31
+ * @param {string[]} args
32
+ * @returns {{level: "L0"|"L1"|"L2"|"L3"} | {error: string}}
33
+ */
34
+ function parseArgs(args) {
35
+ if (args.length !== 1) {
36
+ return { error: `usage: resolve <${VALID_LEVELS.join('|')}>` };
37
+ }
38
+ const [level] = args;
39
+ if (!VALID_LEVELS.includes(level)) {
40
+ return { error: `unknown level "${level}" — expected one of ${VALID_LEVELS.join(', ')}` };
41
+ }
42
+ return { level: /** @type {"L0"|"L1"|"L2"|"L3"} */ (level) };
43
+ }
44
+
45
+ /**
46
+ * @param {string[]} args
47
+ * @returns {Promise<number>}
48
+ */
49
+ export default async function resolve(args) {
50
+ const parsed = parseArgs(args);
51
+ if ('error' in parsed) {
52
+ log.error(maskSecretTokens(parsed.error));
53
+ return 2;
54
+ }
55
+
56
+ const loaded = await loadProjectConfig();
57
+ if (!loaded.ok) {
58
+ log.error(maskSecretTokens(`${loaded.error}: ${loaded.message}`));
59
+ return 1;
60
+ }
61
+
62
+ let resolved;
63
+ try {
64
+ resolved = resolveLevel(loaded.config, parsed.level);
65
+ } catch (err) {
66
+ log.error(maskSecretTokens(/** @type {Error} */ (err).message));
67
+ return 1;
68
+ }
69
+
70
+ const cli = cliNameForProvider(resolved.provider);
71
+ if (cli === undefined) {
72
+ log.error(maskSecretTokens(`resolve: provider "${resolved.provider}" (from levels.${parsed.level}) has no known CLI mapping`));
73
+ return 1;
74
+ }
75
+
76
+ const output = { provider: resolved.provider, model: resolved.model, effort: resolved.effort ?? null, fallback: resolved.fallback, cli };
77
+ writeSafe(process.stdout, `${JSON.stringify(output)}\n`);
78
+ return 0;
79
+ }