@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,389 @@
1
+ /**
2
+ * The wizard's answers (plan §2, block B13a): what each of the nine steps proposes, from the
3
+ * project on disk and the machine, then an existing `.code-forge.yml` on top (so a re-run
4
+ * proposes what is already there — the idempotent diff), then the flags on top of that.
5
+ * `sources[key]` records where each answer came from: `default`, `existing` or `flag`.
6
+ * Pure apart from the reads in {@link gatherContext}.
7
+ */
8
+
9
+ import { existsSync, readFileSync } from 'node:fs';
10
+ import path from 'node:path';
11
+ import { defaultsForProvider } from '../../config/defaults/index.mjs';
12
+ import { detectGates } from '../../gates/detect.mjs';
13
+ import { detectAgentEnv } from '../agent-env.mjs';
14
+ import { detectAll } from '../detect.mjs';
15
+ import { ANSWER_FLAGS, PROVIDERS } from './flags.mjs';
16
+
17
+ /** Harnesses that expose a subagent tool (§5.1: Claude Code's `Agent`, [A13]). */
18
+ export const SUBAGENT_HARNESSES = Object.freeze(['claude']);
19
+
20
+ /**
21
+ * `copy_untracked` proposed per stack (§2 step 8, C5). Laravel is any PHP project (`composer.json`)
22
+ * with an `artisan` file, whatever test runner it uses; a PHP project without one gets the plain
23
+ * `.env`.
24
+ */
25
+ export const COPY_UNTRACKED = Object.freeze({
26
+ laravel: ['.env', '.env.testing'],
27
+ php: ['.env'],
28
+ node: ['.env', '.env.test'],
29
+ python: ['.env'],
30
+ });
31
+
32
+ /** `link_dirs` per stack: the dependency directories an export links instead of copying. */
33
+ const LINK_DIRS = Object.freeze({
34
+ laravel: ['vendor', 'node_modules'],
35
+ php: ['vendor'],
36
+ node: ['node_modules'],
37
+ python: ['.venv'],
38
+ rust: ['target'],
39
+ go: [],
40
+ unknown: ['vendor', 'node_modules'],
41
+ });
42
+
43
+ /** High-risk paths per stack (v1 §2 step 8: migrations, policies, money, auth/middleware, webhooks). */
44
+ const HIGH_PATHS = Object.freeze({
45
+ laravel: ['database/migrations/**', 'app/Policies/**', 'app/Http/Middleware/**', '**/*Money*', '**/*Webhook*'],
46
+ php: ['**/*Money*', '**/*Webhook*', '**/Auth/**'],
47
+ node: ['**/migrations/**', '**/auth/**', '**/*money*', '**/*webhook*'],
48
+ python: ['**/migrations/**', '**/auth/**', '**/*money*', '**/*webhook*'],
49
+ rust: ['**/auth/**', '**/*money*'],
50
+ go: ['**/auth/**', '**/*money*'],
51
+ unknown: [],
52
+ });
53
+
54
+ /**
55
+ * The proof profile is decided from the project, not from the gate detector's stack name alone:
56
+ * `detectGates` (B5) names only `php-pest`, so a Laravel app on PHPUnit reaches here as `node`
57
+ * (its Vite `package.json`) or `unknown` — `composer.json` + `artisan` is Laravel either way.
58
+ * @param {string} stack - `detectGates().stack`
59
+ * @param {string} cwd
60
+ * @returns {string} the proof profile: `laravel`, `php`, `node`, `python`, `rust`, `go` or `unknown`.
61
+ */
62
+ export function proofProfile(stack, cwd) {
63
+ const composer = existsSync(path.join(cwd, 'composer.json'));
64
+ const php = stack.startsWith('php') || composer;
65
+ if (php && existsSync(path.join(cwd, 'artisan'))) return 'laravel';
66
+ if (stack.startsWith('php')) return 'php';
67
+ // a detected non-PHP stack keeps its word; only an undetected project falls back on composer.json
68
+ if (stack !== 'unknown' && Object.hasOwn(LINK_DIRS, stack)) return stack;
69
+ return composer ? 'php' : 'unknown';
70
+ }
71
+
72
+ /**
73
+ * The provider a level runs on: its own `provider` override (R5), else the default provider.
74
+ * @param {{provider: string, levels: Record<string, any>}} values
75
+ * @param {'L0'|'L1'|'L2'|'L3'} level
76
+ * @returns {string}
77
+ */
78
+ export function effectiveProvider(values, level) {
79
+ const own = values.levels?.[level]?.provider;
80
+ return typeof own === 'string' && own.length > 0 ? own : values.provider;
81
+ }
82
+
83
+ /**
84
+ * Consensus review (§4.4) needs three families: reviewer 1 (the effective L2), reviewer 2
85
+ * (`second_provider`) and a judge (the effective L3) that shares a provider with neither
86
+ * (validator rule 7, `judge-family-collision`). With three providers in the catalog the judge's
87
+ * provider is therefore determined once both reviewers are known.
88
+ * @param {{provider: string, levels: Record<string, any>, multimodel: boolean, second_provider: string|null}} values
89
+ * @returns {{judge: string, reviewers: string[], third: string|null} | null} the collision, with
90
+ * the one provider left for the judge; null when multimodel is off, the second reviewer is not
91
+ * chosen yet, or the judge already differs from both reviewers.
92
+ */
93
+ export function judgeCollision(values) {
94
+ if (values.multimodel !== true || !values.second_provider) return null;
95
+ const reviewers = [effectiveProvider(values, 'L2'), values.second_provider];
96
+ const judge = effectiveProvider(values, 'L3');
97
+ if (!reviewers.includes(judge)) return null;
98
+ return { judge, reviewers, third: PROVIDERS.find((p) => !reviewers.includes(p)) ?? null };
99
+ }
100
+
101
+ /**
102
+ * The judge the wizard proposes: `provider`'s shipped default L3, pinned to that provider.
103
+ * @param {string} provider
104
+ * @returns {{model: string, effort?: string, provider: string}}
105
+ */
106
+ export function proposeJudge(provider) {
107
+ const levels = /** @type {Record<string, any>} */ (defaultsForProvider(provider)?.levels ?? {});
108
+ return { ...levels.L3, provider };
109
+ }
110
+
111
+ /**
112
+ * Step 8's proposal for a stack.
113
+ * @param {string} profile
114
+ * @returns {{high: string[], isolation: 'export', link_dirs: string[], copy_untracked: string[]}}
115
+ */
116
+ export function proposeProof(profile) {
117
+ return {
118
+ high: [...(HIGH_PATHS[profile] ?? [])],
119
+ isolation: 'export',
120
+ link_dirs: [...(LINK_DIRS[profile] ?? LINK_DIRS.unknown)],
121
+ copy_untracked: [...(COPY_UNTRACKED[profile] ?? [])],
122
+ };
123
+ }
124
+
125
+ /**
126
+ * Variables the Codex CLI sets in the environment of the commands it runs. Source: the strings of
127
+ * the installed Codex CLI 0.155.1 binary on this machine (read-only `grep`, 2026-09-25), which
128
+ * contains `CODEX_SANDBOX` and `CODEX_SANDBOX_NETWORK_DISABLED`. User-config variables
129
+ * (`CODEX_HOME`, `CODEX_API_KEY`) are deliberately not here.
130
+ */
131
+ export const CODEX_SESSION_VARS = Object.freeze(['CODEX_SANDBOX', 'CODEX_SANDBOX_NETWORK_DISABLED']);
132
+
133
+ /**
134
+ * The harness the wizard runs under, from the agent env vars. Codex is recognised only by the
135
+ * variables it sets for its own sessions ({@link CODEX_SESSION_VARS}), never by `CODEX_HOME` or
136
+ * `CODEX_API_KEY`, which people set in their normal shells.
137
+ * @param {NodeJS.ProcessEnv} env
138
+ * @returns {{agent: boolean, harness: string|null}}
139
+ */
140
+ export function currentHarness(env) {
141
+ const { source } = detectAgentEnv(env);
142
+ if (source === 'CLAUDECODE' || source === 'CLAUDE_CODE') return { agent: true, harness: 'claude' };
143
+ if (source === 'CURSOR_AGENT') return { agent: true, harness: 'cursor' };
144
+ if (CODEX_SESSION_VARS.some((k) => typeof env[k] === 'string' && env[k].length > 0)) return { agent: true, harness: 'codex' };
145
+ if (source === 'AI_AGENT') return { agent: true, harness: 'unknown' };
146
+ return { agent: false, harness: null };
147
+ }
148
+
149
+ /**
150
+ * Solo is present when `SOLO_MCP_PATH` is set or `~/.claude.json` declares an MCP server whose
151
+ * name contains `solo`.
152
+ * @param {NodeJS.ProcessEnv} env @param {string} home
153
+ * @returns {boolean}
154
+ */
155
+ export function soloPresent(env, home) {
156
+ if (typeof env.SOLO_MCP_PATH === 'string' && env.SOLO_MCP_PATH.length > 0) return true;
157
+ try {
158
+ const parsed = JSON.parse(readFileSync(path.join(home, '.claude.json'), 'utf8'));
159
+ const servers = parsed?.mcpServers && typeof parsed.mcpServers === 'object' ? Object.keys(parsed.mcpServers) : [];
160
+ return servers.some((name) => /solo/i.test(name));
161
+ } catch {
162
+ return false;
163
+ }
164
+ }
165
+
166
+ /** @param {string} dir @returns {boolean} `dir` or an ancestor holds `.git`. */
167
+ function insideGitRepo(dir) {
168
+ let cur = path.resolve(dir);
169
+ for (;;) {
170
+ if (existsSync(path.join(cur, '.git'))) return true;
171
+ const parent = path.dirname(cur);
172
+ if (parent === cur) return false;
173
+ cur = parent;
174
+ }
175
+ }
176
+
177
+ /** @param {string} name @returns {string} */
178
+ function slugify(name) {
179
+ const slug = name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '');
180
+ return slug.length > 0 ? slug : 'project';
181
+ }
182
+
183
+ /**
184
+ * Project name: `package.json` name, else the last segment of `composer.json`'s name, else the
185
+ * directory name.
186
+ * @param {string} cwd
187
+ * @returns {{name: string, slug: string}}
188
+ */
189
+ export function projectIdentity(cwd) {
190
+ for (const [file, pick] of /** @type {Array<[string, (v: any) => unknown]>} */ ([
191
+ ['package.json', (v) => v?.name],
192
+ ['composer.json', (v) => (typeof v?.name === 'string' ? v.name.split('/').pop() : undefined)],
193
+ ])) {
194
+ try {
195
+ const name = pick(JSON.parse(readFileSync(path.join(cwd, file), 'utf8')));
196
+ if (typeof name === 'string' && name.length > 0) {
197
+ const bare = name.startsWith('@') ? name.split('/').pop() : name;
198
+ return { name: bare, slug: slugify(bare) };
199
+ }
200
+ } catch {
201
+ // absent or not JSON: next source
202
+ }
203
+ }
204
+ const base = path.basename(path.resolve(cwd));
205
+ return { name: base, slug: slugify(base) };
206
+ }
207
+
208
+ /**
209
+ * @typedef {object} WizardContext
210
+ * @property {string} cwd
211
+ * @property {string} home
212
+ * @property {NodeJS.ProcessEnv} env
213
+ * @property {ReturnType<typeof detectGates>} detected
214
+ * @property {string} profile
215
+ * @property {boolean} gitRepo
216
+ * @property {string[]} detectedHarnesses
217
+ * @property {{agent: boolean, harness: string|null}} current
218
+ * @property {boolean} solo
219
+ * @property {{name: string, slug: string}} project
220
+ */
221
+
222
+ /**
223
+ * @param {{cwd: string, home: string, env: NodeJS.ProcessEnv}} opts
224
+ * @returns {Promise<WizardContext>}
225
+ */
226
+ export async function gatherContext({ cwd, home, env }) {
227
+ const detected = detectGates(cwd);
228
+ const found = await detectAll({ home, pathEnv: env.PATH ?? '' });
229
+ return {
230
+ cwd,
231
+ home,
232
+ env,
233
+ detected,
234
+ profile: proofProfile(detected.stack, cwd),
235
+ gitRepo: insideGitRepo(cwd),
236
+ detectedHarnesses: found.filter((d) => d.detected).map((d) => d.id),
237
+ current: currentHarness(env),
238
+ solo: soloPresent(env, home),
239
+ project: projectIdentity(cwd),
240
+ };
241
+ }
242
+
243
+ /** @param {unknown} v @returns {any} a deep plain copy */
244
+ function clone(v) {
245
+ return v === undefined ? undefined : JSON.parse(JSON.stringify(v));
246
+ }
247
+
248
+ /** @param {unknown} gate @returns {string[]|null} a schema-valid argv (a `{argv}` descriptor keeps its argv). */
249
+ function gateArgv(gate) {
250
+ if (Array.isArray(gate)) return [...gate];
251
+ if (gate && typeof gate === 'object' && Array.isArray(/** @type {any} */ (gate).argv)) return [.../** @type {any} */ (gate).argv];
252
+ return null;
253
+ }
254
+
255
+ /**
256
+ * @typedef {object} Answers
257
+ * @property {'current'|'recommended'} tools
258
+ * @property {string[]} yes_tools
259
+ * @property {string[]} harnesses
260
+ * @property {'project'|'global'} scope
261
+ * @property {'symlink'|'copy'} method
262
+ * @property {string} provider
263
+ * @property {Record<string, any>} levels
264
+ * @property {boolean} refresh_models
265
+ * @property {boolean} multimodel
266
+ * @property {string|null} second_provider
267
+ * @property {{mode: 'auto'} | {mode: 'ref', ref: string} | {mode: 'none'}} jev
268
+ * @property {string} engine
269
+ * @property {number|null} solo_project
270
+ * @property {{test: string[]|null, lint: string[]|null, types: string[]|null, format: string[]|null}} gates
271
+ * @property {{high: string[], isolation: string, link_dirs: string[], copy_untracked: string[]}} proof
272
+ * @property {boolean} doctor
273
+ */
274
+
275
+ /** The answer keys in step order. */
276
+ export const ANSWER_KEYS = Object.freeze([
277
+ 'tools', 'yes_tools', 'harnesses', 'scope', 'method', 'provider', 'levels', 'refresh_models',
278
+ 'multimodel', 'second_provider', 'jev', 'engine', 'solo_project', 'gates', 'proof', 'doctor',
279
+ ]);
280
+
281
+ /**
282
+ * @param {WizardContext} ctx
283
+ * @param {Record<string, any>|null} existing - the current `.code-forge.yml`, when there is one.
284
+ * @param {Record<string, any>|null} user - the current user-level config (`~/.code-forge/`).
285
+ * @param {Array<{flag: string, value: any}>} given - parsed answer flags.
286
+ * @returns {{values: Answers, sources: Record<string, 'default'|'existing'|'flag'>}}
287
+ */
288
+ export function resolveAnswers(ctx, existing, user, given) {
289
+ /** @type {Record<string, 'default'|'existing'|'flag'>} */
290
+ const sources = {};
291
+ const ex = existing ?? {};
292
+ /** @type {any} */
293
+ const v = {};
294
+ /** @param {string} key @param {unknown} fromExisting @param {() => unknown} fallback */
295
+ const set = (key, fromExisting, fallback) => {
296
+ if (fromExisting !== undefined && fromExisting !== null) {
297
+ v[key] = clone(fromExisting);
298
+ sources[key] = 'existing';
299
+ } else {
300
+ v[key] = fallback();
301
+ sources[key] = 'default';
302
+ }
303
+ };
304
+ const flagsFor = (/** @type {string} */ key) => given.filter((g) => ANSWER_FLAGS[g.flag] === key);
305
+ const lastFlag = (/** @type {string} */ key) => flagsFor(key).at(-1);
306
+ const override = (/** @type {string} */ key, /** @type {unknown} */ value) => {
307
+ v[key] = value;
308
+ sources[key] = 'flag';
309
+ };
310
+
311
+ set('tools', user?.tools, () => 'current');
312
+ if (lastFlag('tools')) override('tools', lastFlag('tools').value);
313
+ set('yes_tools', undefined, () => []);
314
+ if (flagsFor('yes_tools').length > 0) override('yes_tools', [...new Set(flagsFor('yes_tools').map((g) => g.value))]);
315
+
316
+ set('harnesses', ex.harnesses, () =>
317
+ v.tools === 'current' && ctx.current.harness && ctx.current.harness !== 'unknown' ? [ctx.current.harness] : [...ctx.detectedHarnesses],
318
+ );
319
+ if (lastFlag('harnesses')) override('harnesses', lastFlag('harnesses').value);
320
+ set('scope', undefined, () => (ctx.gitRepo ? 'project' : 'global'));
321
+ if (lastFlag('scope')) override('scope', lastFlag('scope').value);
322
+ set('method', undefined, () => 'symlink');
323
+ if (lastFlag('method')) override('method', lastFlag('method').value);
324
+
325
+ set('provider', ex.provider, () => 'anthropic');
326
+ if (lastFlag('provider')) override('provider', lastFlag('provider').value);
327
+ const providerDefaults = defaultsForProvider(v.provider);
328
+ set('levels', ex.provider === v.provider ? ex.levels : undefined, () => clone(providerDefaults.levels));
329
+ if (flagsFor('levels').length > 0) {
330
+ const levels = clone(v.levels);
331
+ for (const g of flagsFor('levels')) levels[g.value.level] = { ...g.value.spec };
332
+ override('levels', levels);
333
+ }
334
+ set('refresh_models', undefined, () => false);
335
+ if (lastFlag('refresh_models')) override('refresh_models', true);
336
+
337
+ set('multimodel', ex.review?.multimodel, () => false);
338
+ if (lastFlag('multimodel')) override('multimodel', lastFlag('multimodel').value);
339
+ set('second_provider', ex.review?.second_provider, () => null);
340
+ if (lastFlag('second_provider')) override('second_provider', lastFlag('second_provider').value);
341
+
342
+ const exJev = typeof ex.keys?.jev === 'string' ? { mode: 'ref', ref: ex.keys.jev } : existing ? { mode: 'none' } : undefined;
343
+ set('jev', exJev, () => ({ mode: 'auto' }));
344
+ const jevFlag = lastFlag('jev');
345
+ if (jevFlag) {
346
+ if (jevFlag.flag === '--no-jev') override('jev', { mode: 'none' });
347
+ else override('jev', { mode: 'ref', ref: jevFlag.flag === '--jev-env' ? `env:${jevFlag.value}` : jevFlag.value });
348
+ }
349
+
350
+ set('engine', ex.engine, () => 'auto');
351
+ if (lastFlag('engine')) override('engine', lastFlag('engine').value);
352
+ set('solo_project', user?.solo?.project_id, () => null);
353
+ if (lastFlag('solo_project')) override('solo_project', lastFlag('solo_project').value);
354
+
355
+ const detectedGates = {
356
+ test: gateArgv(ctx.detected.test),
357
+ lint: gateArgv(ctx.detected.lint),
358
+ types: gateArgv(ctx.detected.types),
359
+ format: gateArgv(ctx.detected.format),
360
+ };
361
+ const exGates = ex.gates && typeof ex.gates === 'object' ? ex.gates : undefined;
362
+ set('gates', exGates ? { ...detectedGates, ...exGates } : undefined, () => detectedGates);
363
+ if (flagsFor('gates').length > 0) {
364
+ const gates = clone(v.gates);
365
+ for (const g of flagsFor('gates')) gates[g.value.name] = g.value.argv;
366
+ override('gates', gates);
367
+ }
368
+
369
+ const proposal = proposeProof(ctx.profile);
370
+ const exProof = ex.proof && typeof ex.proof === 'object'
371
+ ? {
372
+ high: ex.proof.tiers?.high?.paths ?? proposal.high,
373
+ isolation: ex.proof.isolation ?? proposal.isolation,
374
+ link_dirs: ex.proof.export?.link_dirs ?? proposal.link_dirs,
375
+ copy_untracked: ex.proof.export?.copy_untracked ?? proposal.copy_untracked,
376
+ }
377
+ : undefined;
378
+ set('proof', exProof, () => proposal);
379
+ if (flagsFor('proof').length > 0) {
380
+ const proof = clone(v.proof);
381
+ for (const g of flagsFor('proof')) proof[g.value.key] = g.value.value;
382
+ override('proof', proof);
383
+ }
384
+
385
+ set('doctor', undefined, () => true);
386
+ if (lastFlag('doctor')) override('doctor', false);
387
+
388
+ return { values: v, sources };
389
+ }
@@ -0,0 +1,248 @@
1
+ /**
2
+ * `code-forge init` flags (plan §2 table, block B13a). Every answer flag overrides exactly one
3
+ * wizard answer (`ANSWER_FLAGS` maps flag -> answer key); `--no-interaction` is a mode switch and
4
+ * overrides no answer.
5
+ *
6
+ * **Q16 = cut (Ricardo, 2026-09-25):** step 8 asks no mutation-tool or nightly question, so the
7
+ * v1.2 `--no-nightly` flag is gone and `--proof` takes only the keys the schema still has
8
+ * (`isolation`, `high`, `link_dirs`, `copy_untracked`). With `-g` and `-p` counted as two flags
9
+ * (two tokens that each set the scope answer) there are 19 answer flags.
10
+ *
11
+ * Parsing is strict: an unknown flag, a missing value, a repeated single-value flag or a bad value
12
+ * is a usage error. Error messages name the flag, never the value given (a user can paste a key
13
+ * into any flag by mistake).
14
+ */
15
+
16
+ import { isOpRef } from '../../keys/onepassword.mjs';
17
+ import { HARNESSES } from '../harnesses.mjs';
18
+
19
+ export const PROVIDERS = Object.freeze(['anthropic', 'openai', 'xai']);
20
+ /** The engines the wizard offers. `subprocess` is never proposed (R2): a power user types it. */
21
+ export const ENGINE_CHOICES = Object.freeze(['auto', 'solo', 'harness']);
22
+ export const GATE_NAMES = Object.freeze(['test', 'lint', 'types', 'format']);
23
+ export const PROOF_KEYS = Object.freeze(['isolation', 'high', 'link_dirs', 'copy_untracked']);
24
+ /** Tools step 1 knows how to detect (and, for some, install after a per-tool yes). */
25
+ export const TOOL_IDS = Object.freeze(['solo', 'codex', 'grok', 'gemini', 'op']);
26
+
27
+ /** flag (as typed) -> the one answer key it overrides. */
28
+ export const ANSWER_FLAGS = Object.freeze({
29
+ '--tools': 'tools',
30
+ '--yes-tool': 'yes_tools',
31
+ '--harness': 'harnesses',
32
+ '-g': 'scope',
33
+ '-p': 'scope',
34
+ '--copy': 'method',
35
+ '--provider': 'provider',
36
+ '--level': 'levels',
37
+ '--refresh-models': 'refresh_models',
38
+ '--multimodel': 'multimodel',
39
+ '--second-provider': 'second_provider',
40
+ '--jev-ref': 'jev',
41
+ '--jev-env': 'jev',
42
+ '--no-jev': 'jev',
43
+ '--engine': 'engine',
44
+ '--solo-project': 'solo_project',
45
+ '--gate': 'gates',
46
+ '--proof': 'proof',
47
+ '--skip-doctor': 'doctor',
48
+ });
49
+
50
+ const BOOLEAN_FLAGS = new Set(['-g', '-p', '--copy', '--refresh-models', '--no-jev', '--skip-doctor', '--no-interaction']);
51
+ const REPEATABLE = new Set(['--yes-tool', '--level', '--gate', '--proof']);
52
+ const LEVEL_NAMES = new Set(['L0', 'L1', 'L2', 'L3']);
53
+ const ENV_NAME = /^[A-Za-z_][A-Za-z0-9_]*$/;
54
+
55
+ export class UsageError extends Error {}
56
+
57
+ /**
58
+ * Split a gate command into argv the way a shell would split words (single/double quotes,
59
+ * backslash escapes) — without ever running a shell.
60
+ * @param {string} text
61
+ * @returns {string[]}
62
+ */
63
+ export function shellWords(text) {
64
+ const words = [];
65
+ let current = '';
66
+ let has = false;
67
+ /** @type {string|null} */
68
+ let quote = null;
69
+ for (let i = 0; i < text.length; i += 1) {
70
+ const ch = text[i];
71
+ if (quote === "'") {
72
+ if (ch === "'") quote = null;
73
+ else current += ch;
74
+ } else if (ch === '\\' && i + 1 < text.length && quote !== "'") {
75
+ i += 1;
76
+ current += text[i];
77
+ has = true;
78
+ } else if (quote === '"') {
79
+ if (ch === '"') quote = null;
80
+ else current += ch;
81
+ } else if (ch === '"' || ch === "'") {
82
+ quote = ch;
83
+ has = true;
84
+ } else if (/\s/.test(ch)) {
85
+ if (has) words.push(current);
86
+ current = '';
87
+ has = false;
88
+ } else {
89
+ current += ch;
90
+ has = true;
91
+ }
92
+ }
93
+ if (quote !== null) throw new UsageError('--gate: unterminated quote');
94
+ if (has) words.push(current);
95
+ return words;
96
+ }
97
+
98
+ /** @param {string} flag @param {string} value @returns {[string, string]} */
99
+ function keyValue(flag, value) {
100
+ const eq = value.indexOf('=');
101
+ if (eq <= 0) throw new UsageError(`${flag} expects <key>=<value>`);
102
+ return [value.slice(0, eq), value.slice(eq + 1)];
103
+ }
104
+
105
+ /** @param {string} value @returns {string[]} */
106
+ function csv(value) {
107
+ return value.split(',').map((s) => s.trim()).filter((s) => s.length > 0);
108
+ }
109
+
110
+ /**
111
+ * `L1=model[:effort][@provider]`
112
+ * @param {string} value
113
+ * @returns {{level: string, spec: {model: string, effort?: string, provider?: string}}}
114
+ */
115
+ export function parseLevel(value) {
116
+ const [level, rest] = keyValue('--level', value);
117
+ if (!LEVEL_NAMES.has(level)) throw new UsageError('--level: level must be L0, L1, L2 or L3');
118
+ let body = rest;
119
+ /** @type {{model: string, effort?: string, provider?: string}} */
120
+ const spec = { model: '' };
121
+ const at = body.lastIndexOf('@');
122
+ if (at >= 0) {
123
+ const provider = body.slice(at + 1);
124
+ if (!PROVIDERS.includes(provider)) throw new UsageError('--level: unknown provider');
125
+ spec.provider = provider;
126
+ body = body.slice(0, at);
127
+ }
128
+ const colon = body.lastIndexOf(':');
129
+ if (colon >= 0) {
130
+ const effort = body.slice(colon + 1);
131
+ if (effort.length === 0) throw new UsageError('--level: empty effort');
132
+ spec.effort = effort;
133
+ body = body.slice(0, colon);
134
+ }
135
+ if (body.length === 0) throw new UsageError('--level: empty model');
136
+ spec.model = body;
137
+ // key order model, effort, provider — the order the schema documents
138
+ return { level, spec: { model: spec.model, ...(spec.effort ? { effort: spec.effort } : {}), ...(spec.provider ? { provider: spec.provider } : {}) } };
139
+ }
140
+
141
+ /**
142
+ * @param {string} flag @param {string} value
143
+ * @returns {unknown} the typed value for that flag
144
+ */
145
+ function convert(flag, value) {
146
+ switch (flag) {
147
+ case '--tools':
148
+ if (value !== 'recommended' && value !== 'current') throw new UsageError('--tools must be recommended or current');
149
+ return value;
150
+ case '--yes-tool':
151
+ if (!TOOL_IDS.includes(value)) throw new UsageError(`--yes-tool must be one of ${TOOL_IDS.join(', ')}`);
152
+ return value;
153
+ case '--harness': {
154
+ const ids = csv(value);
155
+ const known = HARNESSES.map((h) => h.id);
156
+ if (ids.length === 0 || ids.some((id) => !known.includes(id))) throw new UsageError(`--harness takes a comma list of ${known.join(', ')}`);
157
+ return [...new Set(ids)];
158
+ }
159
+ case '--provider':
160
+ case '--second-provider':
161
+ if (!PROVIDERS.includes(value)) throw new UsageError(`${flag} must be one of ${PROVIDERS.join(', ')}`);
162
+ return value;
163
+ case '--level':
164
+ return parseLevel(value);
165
+ case '--multimodel':
166
+ if (value !== 'on' && value !== 'off') throw new UsageError('--multimodel must be on or off');
167
+ return value === 'on';
168
+ case '--jev-ref':
169
+ if (!isOpRef(value)) throw new UsageError('--jev-ref must be an op://vault/item/field reference');
170
+ return value;
171
+ case '--jev-env':
172
+ if (!ENV_NAME.test(value)) throw new UsageError('--jev-env must be an environment variable name');
173
+ return value;
174
+ case '--engine':
175
+ if (value === 'subprocess') {
176
+ throw new UsageError('--engine: init never selects subprocess (R2); type "engine: subprocess" into .code-forge.yml yourself');
177
+ }
178
+ if (!ENGINE_CHOICES.includes(value)) throw new UsageError(`--engine must be one of ${ENGINE_CHOICES.join(', ')}`);
179
+ return value;
180
+ case '--solo-project':
181
+ if (!/^[1-9]\d{0,9}$/.test(value)) throw new UsageError('--solo-project must be a positive integer');
182
+ return Number(value);
183
+ case '--gate': {
184
+ const [name, cmd] = keyValue('--gate', value);
185
+ if (!GATE_NAMES.includes(name)) throw new UsageError(`--gate: gate must be one of ${GATE_NAMES.join(', ')}`);
186
+ if (cmd === 'none' || cmd === 'null') return { name, argv: null };
187
+ const argv = shellWords(cmd);
188
+ if (argv.length === 0) throw new UsageError('--gate: empty command');
189
+ return { name, argv };
190
+ }
191
+ case '--proof': {
192
+ const [key, raw] = keyValue('--proof', value);
193
+ if (!PROOF_KEYS.includes(key)) throw new UsageError(`--proof: key must be one of ${PROOF_KEYS.join(', ')}`);
194
+ if (key === 'isolation') {
195
+ if (raw !== 'export' && raw !== 'lock') throw new UsageError('--proof isolation must be export or lock');
196
+ return { key, value: raw };
197
+ }
198
+ return { key, value: csv(raw) };
199
+ }
200
+ default:
201
+ throw new UsageError(`unknown flag ${flag}`);
202
+ }
203
+ }
204
+
205
+ /**
206
+ * @typedef {object} ParsedFlags
207
+ * @property {boolean} noInteraction
208
+ * @property {Array<{flag: string, value: unknown}>} given - answer flags in the order typed.
209
+ */
210
+
211
+ /**
212
+ * @param {string[]} args
213
+ * @returns {ParsedFlags}
214
+ * @throws {UsageError}
215
+ */
216
+ export function parseInitArgs(args) {
217
+ /** @type {ParsedFlags} */
218
+ const out = { noInteraction: false, given: [] };
219
+ const seen = new Set();
220
+ for (let i = 0; i < args.length; i += 1) {
221
+ const arg = args[i];
222
+ const eq = arg.startsWith('--') ? arg.indexOf('=') : -1;
223
+ const flag = eq > 0 ? arg.slice(0, eq) : arg;
224
+ if (flag !== '--no-interaction' && !Object.hasOwn(ANSWER_FLAGS, flag)) {
225
+ throw new UsageError(arg.startsWith('-') ? `unknown flag ${flag}` : 'init takes no positional argument');
226
+ }
227
+ if (seen.has(flag) && !REPEATABLE.has(flag)) throw new UsageError(`${flag} given more than once`);
228
+ seen.add(flag);
229
+ if (BOOLEAN_FLAGS.has(flag)) {
230
+ if (eq > 0) throw new UsageError(`${flag} takes no value`);
231
+ if (flag === '--no-interaction') out.noInteraction = true;
232
+ else out.given.push({ flag, value: flag === '--copy' ? 'copy' : flag === '-g' ? 'global' : flag === '-p' ? 'project' : true });
233
+ continue;
234
+ }
235
+ let value;
236
+ if (eq > 0) value = arg.slice(eq + 1);
237
+ else {
238
+ value = args[i + 1];
239
+ i += 1;
240
+ }
241
+ if (typeof value !== 'string' || value.length === 0 || (eq < 0 && value.startsWith('-'))) throw new UsageError(`${flag} needs a value`);
242
+ out.given.push({ flag, value: convert(flag, value) });
243
+ }
244
+ if (seen.has('-g') && seen.has('-p')) throw new UsageError('-g and -p are exclusive');
245
+ const jev = ['--jev-ref', '--jev-env', '--no-jev'].filter((f) => seen.has(f));
246
+ if (jev.length > 1) throw new UsageError(`${jev.join(' and ')} are exclusive`);
247
+ return out;
248
+ }