@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,280 @@
1
+ /**
2
+ * `code-forge gates detect|run|secret-scan|safe-edit|scope|acceptance|transcript-grep` (plan
3
+ * §2.2, §4.6, §8.6). Every subcommand prints one JSON object (redacted — `redactJSON`) to stdout
4
+ * and returns a process exit code: `0` gate green/check passed, `1` gate red/check failed,
5
+ * `2` usage error.
6
+ *
7
+ * gates detect --cwd <dir>
8
+ * gates run --cwd <dir> [--slug <slug>] [--timeout-ms <n>] [--run <r> --block <id>]
9
+ * (with --run/--block the gates run behind the proof lock, `runGatesGuarded`: another block
10
+ * holding the lock ⇒ `{"result":"proof.busy","holder":…}` and exit 1)
11
+ * gates secret-scan --cwd <dir> --file <path> [--file <path> ...]
12
+ * gates safe-edit --cwd <dir> --base <sha>
13
+ * gates scope --cwd <dir> --base <sha>
14
+ * gates acceptance --clauses <file.json> --tap <file>
15
+ * gates transcript-grep --file <path>
16
+ */
17
+
18
+ import { readFile } from 'node:fs/promises';
19
+ import { writeSafe, redactJSON } from '../util/redact.mjs';
20
+ import { appendRow } from '../ledger/write.mjs';
21
+ import { loadProjectConfig } from '../config/load.mjs';
22
+ import { detectGates } from '../gates/detect.mjs';
23
+ import { runGates } from '../gates/run.mjs';
24
+ import { runGatesGuarded } from '../proof/lock.mjs';
25
+ import { scanFiles } from '../gates/secret-scan.mjs';
26
+ import { checkSafeEdit } from '../gates/safe-edit.mjs';
27
+ import { computeFileSet } from '../gates/scope.mjs';
28
+ import { checkAcceptance, parseTapPassed } from '../gates/acceptance.mjs';
29
+ import { checkTranscript } from '../gates/transcript-grep.mjs';
30
+
31
+ /**
32
+ * Auto-detected gates (`detectGates`), overridden per-key by an on-disk `.code-forge.yml`'s
33
+ * `gates.{test,lint,types,format}` when that key is explicitly present — a project that already
34
+ * confirmed/edited its gates at `init` step 7 (plan §2 step 7) is authoritative over the table.
35
+ * A missing/unparseable config file is not an error here: detection alone is a valid answer.
36
+ * @param {string} cwd
37
+ * @returns {Promise<import('../gates/detect.mjs').DetectedGates>}
38
+ */
39
+ async function resolveGates(cwd) {
40
+ const detected = detectGates(cwd);
41
+ const loaded = await loadProjectConfig(cwd);
42
+ const configured = loaded.ok && loaded.config?.gates && typeof loaded.config.gates === 'object' ? loaded.config.gates : null;
43
+ if (!configured) return detected;
44
+ return {
45
+ stack: detected.stack,
46
+ test: 'test' in configured ? configured.test : detected.test,
47
+ lint: 'lint' in configured ? configured.lint : detected.lint,
48
+ types: 'types' in configured ? configured.types : detected.types,
49
+ format: 'format' in configured ? configured.format : detected.format,
50
+ };
51
+ }
52
+
53
+ /**
54
+ * `production.markers`/`production.names` from an on-disk config, merged into the forbidden list
55
+ * `transcript-grep` scans with — absent/unparseable config yields no extra tokens.
56
+ * @param {string} cwd
57
+ * @returns {Promise<string[]>}
58
+ */
59
+ async function resolveProductionMarkers(cwd) {
60
+ const loaded = await loadProjectConfig(cwd);
61
+ if (!loaded.ok || !loaded.config?.production) return [];
62
+ const { markers, names } = loaded.config.production;
63
+ return [...(Array.isArray(markers) ? markers : []), ...(Array.isArray(names) ? names : [])];
64
+ }
65
+
66
+ /**
67
+ * @param {string[]} args @param {string} flag
68
+ * @returns {{ok: true, value: string|undefined} | {ok: false}} see `ledger.mjs`'s twin helper —
69
+ * `ok: false` means the flag WAS given with no usable value (missing, or the next token is
70
+ * itself another flag), which a caller must treat as a usage error rather than a silent default.
71
+ */
72
+ function readOptionalFlag(args, flag) {
73
+ const i = args.indexOf(flag);
74
+ if (i < 0) return { ok: true, value: undefined };
75
+ const next = args[i + 1];
76
+ if (next === undefined || next.startsWith('--')) return { ok: false };
77
+ return { ok: true, value: next };
78
+ }
79
+
80
+ /** @param {string[]} args @param {string} flag @returns {string|undefined} */
81
+ function flagValue(args, flag) {
82
+ const result = readOptionalFlag(args, flag);
83
+ return result.ok ? result.value : undefined;
84
+ }
85
+
86
+ /**
87
+ * Resolve `--cwd`, defaulting to `process.cwd()` ONLY when the flag is absent entirely. A flag
88
+ * given with no usable value (`--cwd` at the end of argv, or immediately followed by another
89
+ * flag) is a usage error, not a silent fallback to the wrong directory — every subcommand that
90
+ * reads files or runs commands against a project root depends on this (finding: `--cwd` used to
91
+ * go through `flagValue(...) ?? process.cwd()`, which cannot tell "absent" from "malformed" apart
92
+ * and silently ran gates in the wrong directory for the latter).
93
+ * @param {string[]} args @param {(s: string) => void} err @param {string} label
94
+ * @returns {{ok: true, value: string} | {ok: false}}
95
+ */
96
+ function resolveCwd(args, err, label) {
97
+ const flag = readOptionalFlag(args, '--cwd');
98
+ if (!flag.ok) {
99
+ err(`${label}: --cwd requires a value\n`);
100
+ return { ok: false };
101
+ }
102
+ return { ok: true, value: flag.value ?? process.cwd() };
103
+ }
104
+
105
+ /** @param {string[]} args @param {string} flag @returns {string[]} every value of a repeatable flag. */
106
+ function repeatedFlagValues(args, flag) {
107
+ const values = [];
108
+ for (let i = 0; i < args.length; i += 1) {
109
+ if (args[i] === flag && i + 1 < args.length) values.push(args[i + 1]);
110
+ }
111
+ return values;
112
+ }
113
+
114
+ /**
115
+ * @param {string[]} args
116
+ * @param {{stdout?: {write: (s: string) => unknown}, stderr?: {write: (s: string) => unknown}}} [deps]
117
+ * @returns {Promise<number>}
118
+ */
119
+ export async function runGatesVerb(args, deps = {}) {
120
+ const { stdout = process.stdout, stderr = process.stderr } = deps;
121
+ const out = (/** @type {object} */ value) => writeSafe(stdout, `${redactJSON(value)}\n`);
122
+ const err = (/** @type {string} */ s) => writeSafe(stderr, s);
123
+ const usage = () =>
124
+ err('usage: code-forge gates detect|run|secret-scan|safe-edit|scope|acceptance|transcript-grep --cwd <dir> [...]\n');
125
+
126
+ const [sub] = args;
127
+
128
+ try {
129
+ if (sub === 'detect') {
130
+ const cwdArg = resolveCwd(args, err, 'gates detect');
131
+ if (!cwdArg.ok) return 2;
132
+ out(await resolveGates(cwdArg.value));
133
+ return 0;
134
+ }
135
+
136
+ if (sub === 'run') {
137
+ const cwdArg = resolveCwd(args, err, 'gates run');
138
+ if (!cwdArg.ok) return 2;
139
+ const cwd = cwdArg.value;
140
+
141
+ const timeoutFlag = readOptionalFlag(args, '--timeout-ms');
142
+ if (!timeoutFlag.ok) {
143
+ err('gates run: --timeout-ms requires a value\n');
144
+ return 2;
145
+ }
146
+ const timeoutMs = timeoutFlag.value !== undefined ? Number(timeoutFlag.value) : undefined;
147
+ if (timeoutMs !== undefined && !(Number.isFinite(timeoutMs) && timeoutMs > 0)) {
148
+ err(`gates run: --timeout-ms must be a finite number > 0, got "${timeoutFlag.value}"\n`);
149
+ return 2;
150
+ }
151
+
152
+ // `--slug` has no sensible default (unlike `--cwd`): given-but-malformed must refuse, not
153
+ // silently run the gates while dropping every ledger row on the floor.
154
+ const slugFlag = readOptionalFlag(args, '--slug');
155
+ if (!slugFlag.ok) {
156
+ err('gates run: --slug requires a value\n');
157
+ return 2;
158
+ }
159
+ const slug = slugFlag.value;
160
+
161
+ const runFlag = readOptionalFlag(args, '--run');
162
+ const blockFlag = readOptionalFlag(args, '--block');
163
+ if (!runFlag.ok || !blockFlag.ok || (runFlag.value === undefined) !== (blockFlag.value === undefined)) {
164
+ err('gates run: --run <id> and --block <id> go together, each with a value\n');
165
+ return 2;
166
+ }
167
+
168
+ const gates = await resolveGates(cwd);
169
+ let ran;
170
+ if (runFlag.value !== undefined && blockFlag.value !== undefined) {
171
+ const guarded = await runGatesGuarded({ runId: runFlag.value, blockId: blockFlag.value, gates, cwd, ...(timeoutMs ? { timeoutMs } : {}) });
172
+ if (guarded.result === 'proof.busy') {
173
+ out({ result: 'proof.busy', holder: /** @type {{holder: string}} */ (guarded).holder });
174
+ return 1;
175
+ }
176
+ ran = /** @type {{value: {results: any[], allOk: boolean}}} */ (guarded).value;
177
+ } else {
178
+ ran = await runGates(gates, { cwd, ...(timeoutMs ? { timeoutMs } : {}) });
179
+ }
180
+ const { results, allOk } = ran;
181
+
182
+ if (slug) {
183
+ for (const result of results) {
184
+ if (!result.skipped && !result.ok) {
185
+ await appendRow({ event: 'gate.red', gate: result.gate, code: result.code }, { slug });
186
+ }
187
+ }
188
+ await appendRow({ event: 'gate.done', stack: gates.stack, all_ok: allOk }, { slug });
189
+ }
190
+ out({ stack: gates.stack, results, allOk });
191
+ return allOk ? 0 : 1;
192
+ }
193
+
194
+ if (sub === 'secret-scan') {
195
+ const cwdArg = resolveCwd(args, err, 'gates secret-scan');
196
+ if (!cwdArg.ok) return 2;
197
+ const files = repeatedFlagValues(args, '--file');
198
+ if (files.length === 0) {
199
+ err('gates secret-scan: at least one --file <path> is required\n');
200
+ return 2;
201
+ }
202
+ const hits = await scanFiles(cwdArg.value, files);
203
+ out({ hits, count: hits.length });
204
+ return hits.length === 0 ? 0 : 1;
205
+ }
206
+
207
+ if (sub === 'safe-edit') {
208
+ const cwdArg = resolveCwd(args, err, 'gates safe-edit');
209
+ if (!cwdArg.ok) return 2;
210
+ const base = flagValue(args, '--base');
211
+ if (!base) {
212
+ err('gates safe-edit: --base <sha> is required\n');
213
+ return 2;
214
+ }
215
+ const result = await checkSafeEdit({ cwd: cwdArg.value, base });
216
+ out(result);
217
+ return result.ok ? 0 : 1;
218
+ }
219
+
220
+ if (sub === 'scope') {
221
+ const cwdArg = resolveCwd(args, err, 'gates scope');
222
+ if (!cwdArg.ok) return 2;
223
+ const base = flagValue(args, '--base');
224
+ if (!base) {
225
+ err('gates scope: --base <sha> is required\n');
226
+ return 2;
227
+ }
228
+ const fileSet = await computeFileSet({ cwd: cwdArg.value, base });
229
+ out(fileSet);
230
+ return 0;
231
+ }
232
+
233
+ if (sub === 'acceptance') {
234
+ const clausesPath = flagValue(args, '--clauses');
235
+ const tapPath = flagValue(args, '--tap');
236
+ if (!clausesPath || !tapPath) {
237
+ err('gates acceptance: --clauses <file.json> and --tap <file> are required\n');
238
+ return 2;
239
+ }
240
+ const clauses = JSON.parse(await readFile(clausesPath, 'utf8'));
241
+ const tapOutput = await readFile(tapPath, 'utf8');
242
+ const { passed, failed } = parseTapPassed(tapOutput);
243
+ const result = checkAcceptance(clauses, passed, failed);
244
+ out(result);
245
+ return result.ok ? 0 : 1;
246
+ }
247
+
248
+ if (sub === 'transcript-grep') {
249
+ const filePath = flagValue(args, '--file');
250
+ if (!filePath) {
251
+ err('gates transcript-grep: --file <path> is required\n');
252
+ return 2;
253
+ }
254
+ // --cwd is OPTIONAL here (only used to pull in production.markers/names) — absent entirely
255
+ // means "no config lookup", but given-with-no-value is still a usage error, not silently
256
+ // treated the same as absent.
257
+ const cwdFlag = readOptionalFlag(args, '--cwd');
258
+ if (!cwdFlag.ok) {
259
+ err('gates transcript-grep: --cwd requires a value\n');
260
+ return 2;
261
+ }
262
+ const text = await readFile(filePath, 'utf8');
263
+ const extraTokens = cwdFlag.value ? await resolveProductionMarkers(cwdFlag.value) : [];
264
+ const result = checkTranscript(text, { extraTokens });
265
+ out(result);
266
+ return result.ok ? 0 : 1;
267
+ }
268
+ } catch (thrown) {
269
+ err(`gates ${sub}: ${thrown?.message ?? String(thrown)}\n`);
270
+ return 1;
271
+ }
272
+
273
+ usage();
274
+ return 2;
275
+ }
276
+
277
+ /** @param {string[]} args @returns {Promise<number>} */
278
+ export default async function gates(args) {
279
+ return runGatesVerb(args);
280
+ }
@@ -0,0 +1,48 @@
1
+ /**
2
+ * `code-forge help` (also reached via `--help`/`-h`/no verb, aliased by `bin/code-forge.mjs`).
3
+ * Lists every discovered verb, one per line, each indented by exactly two spaces so a test (or a
4
+ * script) can count them reliably: `/^ {2}\S+$/`.
5
+ */
6
+
7
+ import { readdir } from 'node:fs/promises';
8
+ import path from 'node:path';
9
+ import { fileURLToPath } from 'node:url';
10
+
11
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
12
+
13
+ /**
14
+ * A verb file's basename must match this. Duplicated (not imported) from `bin/code-forge.mjs` —
15
+ * both B0-owned files must stay in sync since there is no shared-util file in B0's scope to hold
16
+ * this once.
17
+ */
18
+ const VERB_FILE_PATTERN = /^[a-z][a-z0-9-]*\.mjs$/;
19
+
20
+ /**
21
+ * Fallback discovery used only when this verb is called without the router's `context.verbs`
22
+ * (i.e. not through `bin/code-forge.mjs` — covered by the "help() with no context" test in
23
+ * `test/router.test.mjs`, so this path is exercised, not dead code).
24
+ * @returns {Promise<string[]>}
25
+ */
26
+ async function discoverVerbs() {
27
+ const entries = await readdir(__dirname, { withFileTypes: true });
28
+ return entries
29
+ .filter((entry) => entry.isFile() && VERB_FILE_PATTERN.test(entry.name))
30
+ .map((entry) => entry.name.slice(0, -'.mjs'.length))
31
+ .sort();
32
+ }
33
+
34
+ /**
35
+ * @param {string[]} _args
36
+ * @param {{verbs?: string[]}} [context] - the router already knows the verb list; reuse it when
37
+ * present instead of reading the directory a second time.
38
+ * @returns {Promise<number>}
39
+ */
40
+ export default async function help(_args, context = {}) {
41
+ const verbs = context.verbs ?? (await discoverVerbs());
42
+ process.stdout.write('Usage: code-forge <verb> [args]\n\n');
43
+ process.stdout.write('Verbs:\n');
44
+ for (const verb of verbs) {
45
+ process.stdout.write(` ${verb}\n`);
46
+ }
47
+ return 0;
48
+ }
@@ -0,0 +1,14 @@
1
+ /**
2
+ * `code-forge init` — the setup wizard (plan §2; block B13a). The wizard lives in
3
+ * `src/install/wizard/`; this verb only hands it the process's argv and streams.
4
+ */
5
+
6
+ import { runInit } from '../install/wizard/run.mjs';
7
+
8
+ /**
9
+ * @param {string[]} args
10
+ * @returns {Promise<number>}
11
+ */
12
+ export default async function init(args) {
13
+ return runInit(args);
14
+ }
@@ -0,0 +1,175 @@
1
+ /**
2
+ * `code-forge jev ask <question-id> --state <file>` (plan §3.2, B3's row in §10.3). A direct,
3
+ * single-question call to Jev — the debugging/manual-use entry point, not the orchestrator's own
4
+ * decision pipeline (that lives inside B9's session runner and B12's review engine, which import
5
+ * `src/decide/**` directly rather than shelling out to this verb).
6
+ *
7
+ * jev ask <id> --state <file.json> [--cwd <dir>] [--slug <slug>] [--key-ref <ref>]
8
+ *
9
+ * Resolves the Jev key through B2's chain (`resolveKey`, earlier batch — the seam rule allows a
10
+ * Wave 1 module to import a block in an earlier batch statically), asks Jev, runs the answer
11
+ * through `thresholds.decide()`, and appends one `{event: 'decision', ...}` row through B6's
12
+ * writer (also an earlier batch) — the shape `ledger/calibration.mjs` already expects:
13
+ * `decision_id`, `question`, `confidence` (plan §6.3's calibration buckets read exactly these).
14
+ *
15
+ * Never prints the key: it only ever reaches the `Authorization` header inside `askJev`, and
16
+ * `resolveKey` has already registered it with `redact()` before this module sees it.
17
+ */
18
+
19
+ import { randomUUID } from 'node:crypto';
20
+ import { readFile } from 'node:fs/promises';
21
+ import { loadConfigFile, DEFAULT_CONFIG_FILENAME } from '../config/load.mjs';
22
+ import path from 'node:path';
23
+ import { buildQuestionPayload, isKnownQuestion, isQuestionDisabled } from '../decide/questions.mjs';
24
+ import { askJev as realAskJev } from '../decide/jev-client.mjs';
25
+ import { decide } from '../decide/thresholds.mjs';
26
+ import { createDefaultKeyStore, resolveKey } from '../keys/store.mjs';
27
+ import { appendRow as realAppendRow } from '../ledger/write.mjs';
28
+ import { writeSafe } from '../util/redact.mjs';
29
+
30
+ const USAGE = 'usage: code-forge jev ask <question-id> --state <file> [--cwd <dir>] [--slug <slug>] [--key-ref <ref>]\n';
31
+
32
+ /**
33
+ * @param {string[]} args @param {string} flag
34
+ * @returns {{ok: true, value: string|undefined} | {ok: false}} same "flag given with no usable
35
+ * value is a usage error" contract as `ledger.mjs`'s `readOptionalFlag` — a bare `--slug --cwd x`
36
+ * must never silently read `--cwd` as `--slug`'s value.
37
+ */
38
+ function readOptionalFlag(args, flag) {
39
+ const i = args.indexOf(flag);
40
+ if (i < 0) return { ok: true, value: undefined };
41
+ const next = args[i + 1];
42
+ if (next === undefined || next.startsWith('--')) return { ok: false };
43
+ return { ok: true, value: next };
44
+ }
45
+
46
+ /**
47
+ * @param {string[]} args
48
+ * @param {object} deps
49
+ * @param {import('../keys/store.mjs').KeyStore} [deps.store]
50
+ * @param {typeof loadConfigFile} [deps.loadConfig]
51
+ * @param {typeof realAskJev} [deps.askJev]
52
+ * @param {typeof realAppendRow} [deps.appendRow]
53
+ * @param {{write: (s: string) => unknown}} [deps.stdout]
54
+ * @param {{write: (s: string) => unknown}} [deps.stderr]
55
+ * @param {NodeJS.ProcessEnv} [deps.env]
56
+ * @param {() => number} [deps.now]
57
+ * @param {() => string} [deps.makeId]
58
+ * @returns {Promise<number>}
59
+ */
60
+ export async function runJev(args, deps = {}) {
61
+ const {
62
+ store,
63
+ loadConfig = loadConfigFile,
64
+ askJev: ask = realAskJev,
65
+ appendRow: write = realAppendRow,
66
+ stdout = process.stdout,
67
+ stderr = process.stderr,
68
+ env = process.env,
69
+ now = Date.now,
70
+ makeId = randomUUID,
71
+ } = deps;
72
+ const out = (/** @type {string} */ s) => writeSafe(stdout, s);
73
+ const err = (/** @type {string} */ s) => writeSafe(stderr, s);
74
+
75
+ const [sub, id] = args;
76
+ if (sub !== 'ask' || typeof id !== 'string' || id.startsWith('--')) {
77
+ err(USAGE);
78
+ return 2;
79
+ }
80
+ if (!isKnownQuestion(id)) {
81
+ err(`jev ask: unknown question id "${id}"\n`);
82
+ return 2;
83
+ }
84
+
85
+ const stateFlag = readOptionalFlag(args, '--state');
86
+ const cwdFlag = readOptionalFlag(args, '--cwd');
87
+ const slugFlag = readOptionalFlag(args, '--slug');
88
+ const keyRefFlag = readOptionalFlag(args, '--key-ref');
89
+ if (!stateFlag.ok || !cwdFlag.ok || !slugFlag.ok || !keyRefFlag.ok || !stateFlag.value) {
90
+ err(USAGE);
91
+ return 2;
92
+ }
93
+ const cwd = cwdFlag.value ?? process.cwd();
94
+
95
+ let state;
96
+ try {
97
+ state = JSON.parse(await readFile(path.resolve(cwd, stateFlag.value), 'utf8'));
98
+ } catch (thrown) {
99
+ err(`jev ask: could not read --state file: ${thrown?.code ?? thrown?.message ?? String(thrown)}\n`);
100
+ return 1;
101
+ }
102
+
103
+ const configPath = path.join(cwd, DEFAULT_CONFIG_FILENAME);
104
+ const loaded = await loadConfig(configPath);
105
+ let cfg = {};
106
+ if (loaded.ok) {
107
+ cfg = loaded.config;
108
+ } else if (loaded.error !== 'not-found') {
109
+ err(`jev ask: ${loaded.message}\n`);
110
+ return 1;
111
+ }
112
+
113
+ if (isQuestionDisabled(id, cfg)) {
114
+ err(`jev ask: question "${id}" is disabled by system1.disable\n`);
115
+ return 1;
116
+ }
117
+
118
+ const keyStore = store ?? (await createDefaultKeyStore(env));
119
+ const keyRef = keyRefFlag.value ?? cfg?.keys?.jev;
120
+ let resolved;
121
+ try {
122
+ resolved = await resolveKey('jev', { store: keyStore, ref: keyRef, env, now: now() });
123
+ } catch (thrown) {
124
+ err(`jev ask: ${thrown?.message ?? String(thrown)}\n`);
125
+ return 2;
126
+ }
127
+ if (resolved.value === null) {
128
+ for (const reason of resolved.errors) {
129
+ err(`jev ask: ${reason}\n`);
130
+ }
131
+ err('jev ask: no Jev key resolved (set one with "code-forge keys set jev")\n');
132
+ return 1;
133
+ }
134
+
135
+ const questions = buildQuestionPayload([id], cfg);
136
+ const result = await ask({ state, questions, key: resolved.value });
137
+ if (!result.ok) {
138
+ const failure = /** @type {import('../decide/jev-client.mjs').JevFailure} */ (result);
139
+ err(`jev ask: request failed (${failure.kind}${failure.status ? `, status ${failure.status}` : ''})\n`);
140
+ return 1;
141
+ }
142
+
143
+ const answer = result.answers[id];
144
+ if (!answer) {
145
+ err(`jev ask: Jev did not answer question "${id}"\n`);
146
+ return 1;
147
+ }
148
+ const decision = decide(id, answer, cfg);
149
+ const slug = slugFlag.value ?? cfg?.project?.slug ?? 'code-forge';
150
+ const decisionId = makeId();
151
+
152
+ await write(
153
+ {
154
+ event: 'decision',
155
+ decision_id: decisionId,
156
+ question: id,
157
+ answer: decision.value,
158
+ stage: decision.stage,
159
+ confidence: decision.confidence,
160
+ margin: decision.margin,
161
+ source: 'jev',
162
+ tokens_in: result.usage?.input_tokens,
163
+ tokens_out: result.usage?.output_tokens,
164
+ },
165
+ { slug },
166
+ );
167
+
168
+ out(`${JSON.stringify({ decision_id: decisionId, question: id, answer, decision }, null, 2)}\n`);
169
+ return 0;
170
+ }
171
+
172
+ /** @param {string[]} args @returns {Promise<number>} */
173
+ export default async function jev(args) {
174
+ return runJev(args, {});
175
+ }
@@ -0,0 +1,170 @@
1
+ /**
2
+ * `code-forge keys set|list|test|remove` (plan §8.1). No subcommand ever prints a key value:
3
+ * `list` shows name, source, backend and expiry from the store's index; `test` reports where a
4
+ * key resolved from. Every write goes through `writeSafe`, so even a value that slipped into a
5
+ * message would be masked once the chain registered it.
6
+ *
7
+ * keys list
8
+ * keys set <name> [--op <op://ref>] hidden prompt, or read once from 1Password and cache 8 h
9
+ * keys test <name> [--ref <ref>] resolve through the chain (no prompt)
10
+ * keys remove <name>
11
+ *
12
+ * Every name is validated before any store call, on every subcommand.
13
+ */
14
+
15
+ import { writeSafe } from '../util/redact.mjs';
16
+ import { isOpRef } from '../keys/onepassword.mjs';
17
+ import { assertKeyName, createDefaultKeyStore, parseRef, readOpAndCache, resolveKey } from '../keys/store.mjs';
18
+
19
+ const USAGE = 'usage: code-forge keys list | set <name> [--op <ref>] | test <name> [--ref <ref>] | remove <name>\n';
20
+
21
+ /** The one flag each subcommand accepts (list and remove take none). */
22
+ const FLAGS = /** @type {Record<string, string|null>} */ ({ list: null, set: '--op', test: '--ref', remove: null });
23
+
24
+ /**
25
+ * @param {number|null} exp
26
+ * @param {number} now
27
+ * @returns {string}
28
+ */
29
+ export function formatExpiry(exp, now) {
30
+ if (exp === null) {
31
+ return 'never';
32
+ }
33
+ return exp <= now ? 'expired' : new Date(exp).toISOString();
34
+ }
35
+
36
+ /**
37
+ * Strict parse: `<sub> [<name>] [<flag> <value>]`, in that order. A flag with no value, a value
38
+ * that looks like a flag, a name that looks like a flag, an unknown flag or an extra argument is
39
+ * a usage error — never silently reinterpreted.
40
+ * @param {string[]} args
41
+ * @returns {{sub: string, name?: string, flagArg?: string} | null}
42
+ */
43
+ export function parseKeysArgs(args) {
44
+ const [sub, ...rest] = args;
45
+ if (typeof sub !== 'string' || !Object.hasOwn(FLAGS, sub)) {
46
+ return null;
47
+ }
48
+ if (sub === 'list') {
49
+ return rest.length === 0 ? { sub } : null;
50
+ }
51
+ const [name, flagName, flagArg, ...extra] = rest;
52
+ if (typeof name !== 'string' || name.startsWith('-') || extra.length > 0) {
53
+ return null;
54
+ }
55
+ if (flagName === undefined) {
56
+ return { sub, name };
57
+ }
58
+ if (flagName !== FLAGS[sub] || typeof flagArg !== 'string' || flagArg.length === 0 || flagArg.startsWith('-')) {
59
+ return null;
60
+ }
61
+ return { sub, name, flagArg };
62
+ }
63
+
64
+ /** @param {string} name */
65
+ async function askHidden(name) {
66
+ const { password, isCancel } = await import('@clack/prompts');
67
+ const typed = await password({ message: `Value for key "${name}" (input hidden)` });
68
+ return isCancel(typed) ? null : String(typed);
69
+ }
70
+
71
+ /**
72
+ * @param {string[]} args
73
+ * @param {object} deps
74
+ * @param {import('../keys/store.mjs').KeyStore} deps.store
75
+ * @param {{write: (s: string) => unknown}} [deps.stdout]
76
+ * @param {{write: (s: string) => unknown}} [deps.stderr]
77
+ * @param {(name: string) => Promise<string|null>} [deps.ask]
78
+ * @param {typeof import('../keys/onepassword.mjs').opRead} [deps.opRead]
79
+ * @param {NodeJS.ProcessEnv} [deps.env]
80
+ * @param {number} [deps.now]
81
+ * @returns {Promise<number>}
82
+ */
83
+ export async function runKeys(args, { store, stdout = process.stdout, stderr = process.stderr, ask = askHidden, opRead, env = process.env, now = Date.now() }) {
84
+ const out = (/** @type {string} */ s) => writeSafe(stdout, s);
85
+ const err = (/** @type {string} */ s) => writeSafe(stderr, s);
86
+
87
+ const parsed = parseKeysArgs(args);
88
+ if (!parsed) {
89
+ err(USAGE);
90
+ return 2;
91
+ }
92
+ const { sub, name, flagArg } = parsed;
93
+ try {
94
+ if (name !== undefined) {
95
+ assertKeyName(name);
96
+ }
97
+ if (sub === 'test' && flagArg !== undefined) {
98
+ parseRef(name, flagArg);
99
+ }
100
+ } catch (e) {
101
+ err(`keys: ${e.message}\n`);
102
+ return 2;
103
+ }
104
+ if (sub === 'set' && flagArg !== undefined && !isOpRef(flagArg)) {
105
+ err(`${name}: --op must be an op://vault/item/field reference\n`);
106
+ return 2;
107
+ }
108
+
109
+ if (store.warning) {
110
+ err(`WARN ${store.warning}\n`);
111
+ }
112
+
113
+ if (sub === 'list') {
114
+ const rows = await store.list();
115
+ out('NAME\tSOURCE\tBACKEND\tEXPIRES\n');
116
+ for (const row of rows) {
117
+ out(`${row.name}\t${row.source}\t${row.backend}\t${formatExpiry(row.exp, now)}\n`);
118
+ }
119
+ return 0;
120
+ }
121
+
122
+ if (sub === 'remove') {
123
+ const removed = await store.remove(name);
124
+ out(`removed ${name} (${removed} backend${removed === 1 ? '' : 's'})\n`);
125
+ return 0;
126
+ }
127
+
128
+ if (sub === 'test') {
129
+ const res = await resolveKey(name, { store, ref: flagArg, env, opRead, now });
130
+ for (const e of res.errors) {
131
+ err(`${name}: ${e}\n`);
132
+ }
133
+ if (res.value === null) {
134
+ err(`${name}: not found\n`);
135
+ return 1;
136
+ }
137
+ out(`${name}: ok source=${res.source} expires=${formatExpiry(res.exp, now)}\n`);
138
+ return 0;
139
+ }
140
+
141
+ if (flagArg !== undefined) {
142
+ const res = await readOpAndCache(name, flagArg, { store, opRead, now });
143
+ for (const e of res.errors) {
144
+ err(`${name}: ${e}\n`);
145
+ }
146
+ // A null value always carries at least one reason (`readOpAndCache` guarantees it).
147
+ if (res.value === null) {
148
+ return 1;
149
+ }
150
+ out(`stored ${name} in ${store.writer} (source=op, expires=${formatExpiry(res.exp, now)})\n`);
151
+ return 0;
152
+ }
153
+
154
+ const typed = await ask(name);
155
+ if (typeof typed !== 'string' || typed.length === 0) {
156
+ err(`${name}: nothing stored\n`);
157
+ return 1;
158
+ }
159
+ await store.put(name, typed, { source: 'user', exp: null });
160
+ out(`stored ${name} in ${store.writer} (source=user, expires=never)\n`);
161
+ return 0;
162
+ }
163
+
164
+ /**
165
+ * @param {string[]} args
166
+ * @returns {Promise<number>}
167
+ */
168
+ export default async function keys(args) {
169
+ return runKeys(args, { store: await createDefaultKeyStore() });
170
+ }