@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,128 @@
1
+ /**
2
+ * From answers to `.code-forge.yml` (plan §1.3 keys, §2 "Writes" column; block B13a). The config
3
+ * holds key REFERENCES only (`keys.jev: env:NAME | op://… | user`), never a value. A re-run merges
4
+ * the wizard's keys into the existing document: keys the wizard does not manage are kept as they
5
+ * are, and when nothing changed the file is not rewritten (0 bytes changed).
6
+ */
7
+
8
+ import { isDeepStrictEqual } from 'node:util';
9
+ import { stringify } from 'yaml';
10
+ import { defaultsForProvider } from '../../config/defaults/index.mjs';
11
+
12
+ /**
13
+ * The proposed `caps.coders` (plan §5.5, B19): without Solo there are no locks, so the engine that
14
+ * `auto` resolves to (harness or subprocess) runs ONE coder; with Solo (`engine: solo`, or `auto`
15
+ * with Solo present) the provider default (2). R10's two-coder lift is a rule of the package's own
16
+ * build, not the product default.
17
+ * @param {string} engine @param {boolean} solo @param {number} withSolo
18
+ * @returns {number}
19
+ */
20
+ export function proposedCoders(engine, solo, withSolo) {
21
+ return engine === 'solo' || (engine === 'auto' && solo) ? withSolo : 1;
22
+ }
23
+
24
+ /**
25
+ * @param {import('./answers.mjs').Answers} a
26
+ * @param {{project: {name: string, slug: string}, solo?: boolean}} ctx
27
+ * @param {string|null} jevRef - the resolved `keys.jev` reference, or null for "no Jev key".
28
+ * @returns {Record<string, any>} the wizard-managed part of the config, in documented key order.
29
+ */
30
+ export function generatedConfig(a, ctx, jevRef) {
31
+ const defaults = defaultsForProvider(a.provider);
32
+ /** @type {Record<string, any>} */
33
+ const review = { multimodel: a.multimodel };
34
+ if (a.multimodel && a.second_provider) {
35
+ const second = /** @type {Record<string, any>} */ (defaultsForProvider(a.second_provider).levels);
36
+ review.second_provider = a.second_provider;
37
+ review.second_levels = { L2: { ...second.L2, provider: a.second_provider }, L3: { ...second.L3, provider: a.second_provider } };
38
+ }
39
+ /** @type {Record<string, any>} */
40
+ const cfg = {
41
+ version: 1,
42
+ project: { name: ctx.project.name, slug: ctx.project.slug },
43
+ provider: a.provider,
44
+ levels: a.levels,
45
+ caps: { coders: proposedCoders(a.engine, ctx.solo === true, defaults.caps.coders) },
46
+ review,
47
+ engine: a.engine,
48
+ harnesses: [...a.harnesses],
49
+ };
50
+ if (jevRef) cfg.keys = { jev: jevRef };
51
+ else cfg.system1 = { fallback: 'rules' };
52
+ cfg.gates = { test: a.gates.test, lint: a.gates.lint, types: a.gates.types, format: a.gates.format };
53
+ cfg.proof = {
54
+ tiers: { high: { paths: [...a.proof.high] } },
55
+ isolation: a.proof.isolation,
56
+ export: { link_dirs: [...a.proof.link_dirs], copy_untracked: [...a.proof.copy_untracked] },
57
+ };
58
+ return cfg;
59
+ }
60
+
61
+ /** @param {unknown} v @returns {v is Record<string, any>} */
62
+ function isMap(v) {
63
+ return v !== null && typeof v === 'object' && !Array.isArray(v);
64
+ }
65
+
66
+ /**
67
+ * `base` with `over` merged in: maps merge key by key (base order kept, new keys appended),
68
+ * anything else (arrays, scalars, null) is replaced by `over`'s value.
69
+ * @param {Record<string, any>} base @param {Record<string, any>} over
70
+ * @returns {Record<string, any>}
71
+ */
72
+ export function mergeInto(base, over) {
73
+ /** @type {Record<string, any>} */
74
+ const out = { ...base };
75
+ for (const [key, value] of Object.entries(over)) {
76
+ out[key] = isMap(value) && isMap(base[key]) ? mergeInto(base[key], value) : value;
77
+ }
78
+ return out;
79
+ }
80
+
81
+ /**
82
+ * `generatedConfig` writes ONE of `keys.jev` and `system1.fallback: rules`; on a re-run the side it
83
+ * did not write is removed from the existing document, so the two never contradict each other.
84
+ * @param {Record<string, any>|null} existing
85
+ * @param {Record<string, any>} generated
86
+ * @param {{dropJev: boolean}} opts - `--no-jev` (or "skip") removes an existing `keys.jev`; the
87
+ * same follows from `generated` having no `keys.jev`, so the two never disagree.
88
+ * @returns {Record<string, any>}
89
+ */
90
+ export function mergeConfig(existing, generated, { dropJev }) {
91
+ const merged = existing ? mergeInto(existing, generated) : mergeInto({}, generated);
92
+ // multimodel off: a second reviewer from an earlier run must not survive the merge
93
+ if (isMap(merged.review) && !Object.hasOwn(generated.review ?? {}, 'second_provider')) {
94
+ const { second_provider: _p, second_levels: _l, ...review } = merged.review;
95
+ merged.review = review;
96
+ }
97
+ const hasJev = typeof generated.keys?.jev === 'string';
98
+ if ((dropJev || !hasJev) && isMap(merged.keys) && Object.hasOwn(merged.keys, 'jev')) {
99
+ const { jev: _drop, ...rest } = merged.keys;
100
+ if (Object.keys(rest).length > 0) merged.keys = rest;
101
+ else delete merged.keys;
102
+ }
103
+ // a key is referenced: the rules-only fallback of an earlier keyless run goes
104
+ if (hasJev && isMap(merged.system1) && Object.hasOwn(merged.system1, 'fallback')) {
105
+ const { fallback: _drop, ...rest } = merged.system1;
106
+ if (Object.keys(rest).length > 0) merged.system1 = rest;
107
+ else delete merged.system1;
108
+ }
109
+ return merged;
110
+ }
111
+
112
+ /**
113
+ * Dotted key paths whose value differs between `a` and `b` (leaf level; arrays compared whole).
114
+ * @param {unknown} a @param {unknown} b @param {string} [prefix]
115
+ * @returns {string[]}
116
+ */
117
+ export function changedPaths(a, b, prefix = '') {
118
+ if (isMap(a) && isMap(b)) {
119
+ const keys = [...new Set([...Object.keys(a), ...Object.keys(b)])];
120
+ return keys.flatMap((k) => changedPaths(a[k], b[k], prefix ? `${prefix}.${k}` : k));
121
+ }
122
+ return isDeepStrictEqual(a, b) ? [] : [prefix || '(root)'];
123
+ }
124
+
125
+ /** @param {Record<string, any>} cfg @returns {string} the YAML text written to disk. */
126
+ export function serializeConfig(cfg) {
127
+ return stringify(cfg, { lineWidth: 0 });
128
+ }
@@ -0,0 +1,366 @@
1
+ /**
2
+ * `code-forge init` — the nine-step setup wizard (plan §2; block B13a).
3
+ *
4
+ * Order: answers (defaults ← existing config ← flags; then questions when interactive) → step 5
5
+ * key source → build + merge + validate the config (nothing is written when it is invalid or a
6
+ * required answer is missing) → write `.code-forge.yml` only when it changes → tools (step 1),
7
+ * model refresh (3), harness links (2), user config → the §5.1 stop line when the harness has no
8
+ * subagent tool and there is no Solo (6) → doctor `--quick` (9) → output.
9
+ *
10
+ * Output: an agent harness (`CLAUDECODE`, `CLAUDE_CODE`, `CURSOR_AGENT`, `CODEX_SANDBOX*`, `AI_AGENT`) or
11
+ * a non-TTY stdin gets exactly ONE JSON line on stdout — `{ok, wrote, harnesses, engine_stop,
12
+ * doctor, log, log_tail}` on success, `{ok: false, error, wrote, log, log_tail}` on any failure
13
+ * after the flags parsed; a person gets the lines (and `init: <error>` on stderr). Either way the
14
+ * lines go to `~/.code-forge/logs/init-<ts>.log`, on failure too. Every line passes through
15
+ * `redact`; a key typed at the prompt goes to the key store and nowhere else — the config holds its
16
+ * reference (`user`) only.
17
+ */
18
+
19
+ import { existsSync } from 'node:fs';
20
+ import { mkdir, readFile, writeFile } from 'node:fs/promises';
21
+ import path from 'node:path';
22
+ import { fileURLToPath } from 'node:url';
23
+ import { parse as parseYAML } from 'yaml';
24
+ import { DEFAULT_CONFIG_FILENAME, loadConfigFile } from '../../config/load.mjs';
25
+ import { refreshFromCliCaches } from '../../config/refresh.mjs';
26
+ import { validateConfig } from '../../config/validate.mjs';
27
+ import { createDefaultKeyStore, resolveKey } from '../../keys/store.mjs';
28
+ import { exec } from '../../util/exec.mjs';
29
+ import { redact } from '../../util/redact.mjs';
30
+ import { writeAgentJson } from '../agent-env.mjs';
31
+ import { commandOnPath } from '../detect.mjs';
32
+ import { getHarness, globalSkillPath, projectSkillPath } from '../harnesses.mjs';
33
+ import { install, installsPath } from '../link.mjs';
34
+ import { currentHarness, effectiveProvider, gatherContext, judgeCollision, proposeJudge, resolveAnswers, SUBAGENT_HARNESSES } from './answers.mjs';
35
+ import { changedPaths, generatedConfig, mergeConfig, mergeInto, serializeConfig } from './project-file.mjs';
36
+ import { parseInitArgs, UsageError } from './flags.mjs';
37
+ import { askAnswers, askJevSource, CancelledError } from './steps.mjs';
38
+
39
+ /** §5.1's stop text, printed at setup (not at the first run) when there is no engine to use. */
40
+ export const NO_ENGINE_STOP_TEXT = [
41
+ 'code-forge: no Solo and no subagent tool in this harness.',
42
+ ' To run coders as detached CLI processes (power-user mode) add to .code-forge.yml:',
43
+ ' engine: subprocess',
44
+ ' Then re-run. (R2: this engine is never selected automatically.)',
45
+ ].join('\n');
46
+
47
+ /** Step 1's recommended tools: detect, print the install command, install only after a per-tool yes. */
48
+ export const TOOLS = Object.freeze([
49
+ { id: 'solo', command: null, install: null, hint: 'install the Solo app (https://soloterm.com) and add its MCP entry' },
50
+ { id: 'codex', command: 'codex', install: ['npm', 'install', '-g', '@openai/codex'], hint: null },
51
+ { id: 'grok', command: 'grok', install: null, hint: 'install the Grok CLI from its vendor page' },
52
+ { id: 'gemini', command: 'gemini', install: ['npm', 'install', '-g', '@google/gemini-cli'], hint: null },
53
+ { id: 'op', command: 'op', install: ['brew', 'install', '1password-cli'], hint: null },
54
+ ]);
55
+
56
+ export const USAGE = [
57
+ 'usage: code-forge init [--no-interaction] [--tools recommended|current] [--yes-tool <tool>]…',
58
+ ' [--harness a,b] [-g|-p] [--copy] [--provider P] [--level Ln=model[:effort][@provider]]…',
59
+ ' [--refresh-models] [--multimodel on|off] [--second-provider P]',
60
+ ' [--jev-ref op://… | --jev-env NAME | --no-jev] [--engine auto|solo|harness] [--solo-project N]',
61
+ ' [--gate name=cmd]… [--proof isolation=export|lock | high=a,b | link_dirs=a,b | copy_untracked=a,b]… [--skip-doctor]',
62
+ ].join('\n');
63
+
64
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
65
+ /** This package's `skill/` directory (`src/install/wizard` → package root). */
66
+ export const DEFAULT_SKILL_SOURCE = path.join(__dirname, '..', '..', '..', 'skill');
67
+
68
+ /**
69
+ * The user-level config file under `~/.code-forge/` (plan §2: user `tools`, `keys.jev.source`,
70
+ * `solo.project_id`). Spelled in two parts so the schema-coverage sweep does not read the file
71
+ * name as a `config.<path>` access.
72
+ */
73
+ export const USER_CONFIG_FILE = ['config', 'yml'].join('.');
74
+
75
+ /** The environment variable the Jev key is read from when it comes from the environment. */
76
+ const JEV_ENV_NAME = 'CODE_FORGE_KEY_JEV';
77
+
78
+ /** A failure after the flags parsed: carries the exit code; the message names key paths, never values. */
79
+ class InitFailure extends Error {
80
+ /** @param {number} code @param {string} message */
81
+ constructor(code, message) {
82
+ super(message);
83
+ this.code = code;
84
+ }
85
+ }
86
+
87
+ /** @typedef {{write: (s: string) => unknown}} Out */
88
+
89
+ /**
90
+ * @typedef {object} InitDeps
91
+ * @property {string} [cwd]
92
+ * @property {string} [home]
93
+ * @property {NodeJS.ProcessEnv} [env]
94
+ * @property {Out} [stdout]
95
+ * @property {Out} [stderr]
96
+ * @property {boolean} [isTTY] - whether stdin is a terminal (default `process.stdin.isTTY`).
97
+ * @property {import('./steps.mjs').Ui} [ui] - default `@clack/prompts`.
98
+ * @property {() => Promise<any>} [getStore] - the B2 key store (default: the production chain).
99
+ * @property {(opts: {cwd: string, env: NodeJS.ProcessEnv}) => Promise<Array<{status: string, label: string, detail: string}>>} [doctor]
100
+ * @property {(argv: string[]) => Promise<{result: string}>} [installTool]
101
+ * @property {string} [skillSource]
102
+ * @property {() => Date} [now]
103
+ */
104
+
105
+ /** @param {string} file @returns {Promise<Record<string, any>|null>} a missing or unreadable user config reads as null */
106
+ async function readUserConfig(file) {
107
+ try {
108
+ const parsed = parseYAML(await readFile(file, 'utf8'), { prettyErrors: false });
109
+ return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : null;
110
+ } catch {
111
+ return null;
112
+ }
113
+ }
114
+
115
+ /** @param {string} file @param {string} text */
116
+ async function writeText(file, text) {
117
+ await mkdir(path.dirname(file), { recursive: true });
118
+ await writeFile(file, text);
119
+ }
120
+
121
+ /**
122
+ * @param {{cwd: string, env: NodeJS.ProcessEnv}} opts
123
+ * @returns {Promise<Array<{status: string, label: string, detail: string}>>}
124
+ */
125
+ async function quickDoctor({ cwd, env }) {
126
+ const { runDoctor } = await import('../../doctor/index.mjs');
127
+ return runDoctor({ cwd, quick: true }, { env });
128
+ }
129
+
130
+ /**
131
+ * @param {string[]} args
132
+ * @param {InitDeps} [deps]
133
+ * @returns {Promise<number>}
134
+ */
135
+ export async function runInit(args, deps = {}) {
136
+ const env = deps.env ?? process.env;
137
+ const cwd = path.resolve(deps.cwd ?? process.cwd());
138
+ const home = deps.home ?? env.HOME ?? '';
139
+ const stdout = deps.stdout ?? process.stdout;
140
+ const stderr = deps.stderr ?? process.stderr;
141
+ const now = (deps.now ?? (() => new Date()))();
142
+ const lines = /** @type {string[]} */ ([]);
143
+ const say = (/** @type {string} */ line) => lines.push(redact(line));
144
+
145
+ let parsed;
146
+ try {
147
+ parsed = parseInitArgs(args);
148
+ } catch (err) {
149
+ if (!(err instanceof UsageError)) throw err;
150
+ stderr.write(redact(`init: ${err.message}\n${USAGE}\n`));
151
+ return 2;
152
+ }
153
+
154
+ // Everything below the flags reaches stdout through `finish`, once: the log is written on
155
+ // every exit, and agent mode always gets exactly one JSON line.
156
+ const jsonMode = currentHarness(env).agent || !(deps.isTTY ?? process.stdin.isTTY === true);
157
+ const interactive = !parsed.noInteraction && !jsonMode;
158
+ const cfDir = path.join(home, '.code-forge');
159
+ const logFile = home === '' ? null : path.join(cfDir, 'logs', `init-${now.toISOString().replace(/[:.]/g, '-')}.log`);
160
+ /** @type {string[]} */
161
+ const wrote = [];
162
+ /** @type {string[]} */
163
+ const linked = [];
164
+ let engineStop = false;
165
+ /** @type {{ok: boolean, counts: Record<string, number>} | null} */
166
+ let doctor = null;
167
+
168
+ /**
169
+ * The one exit: writes the log, then either the success shape or `{ok: false, error, wrote, …}`.
170
+ * @param {number} code @param {string|null} error
171
+ * @returns {Promise<number>}
172
+ */
173
+ const finish = async (code, error) => {
174
+ if (error !== null) {
175
+ say(`error: ${error}`);
176
+ stderr.write(redact(`init: ${error}\n`));
177
+ }
178
+ let log = logFile;
179
+ if (logFile !== null) {
180
+ try {
181
+ await writeText(logFile, `${lines.join('\n')}\n`);
182
+ } catch {
183
+ log = null;
184
+ }
185
+ }
186
+ const tail = lines.slice(-10);
187
+ if (jsonMode) {
188
+ const body = error === null
189
+ ? { ok: code === 0, wrote, harnesses: linked, engine_stop: engineStop, doctor, log, log_tail: tail }
190
+ : { ok: false, error, wrote, log, log_tail: tail };
191
+ writeAgentJson(JSON.parse(redact(JSON.stringify(body))), { stdout });
192
+ } else if (error === null) {
193
+ stdout.write(`${lines.join('\n')}\nlog: ${log}\n`);
194
+ }
195
+ return code;
196
+ };
197
+
198
+ /** @type {import('./steps.mjs').Ui|undefined} */
199
+ let ui;
200
+ /** @type {any} */
201
+ let store;
202
+ const getStore = async () => (store ??= await (deps.getStore ?? (() => createDefaultKeyStore(env)))());
203
+ try {
204
+ if (home === '') throw new InitFailure(1, 'HOME is not set');
205
+ const ctx = await gatherContext({ cwd, home, env });
206
+
207
+ const configFile = path.join(cwd, DEFAULT_CONFIG_FILENAME);
208
+ /** @type {Record<string, any>|null} */
209
+ let existing = null;
210
+ if (existsSync(configFile)) {
211
+ const loaded = await loadConfigFile(configFile);
212
+ if (!loaded.ok) throw new InitFailure(1, `${DEFAULT_CONFIG_FILENAME} exists but cannot be loaded (${loaded.error}); fix or move it, then re-run`);
213
+ existing = loaded.config;
214
+ }
215
+ const userFile = path.join(cfDir, USER_CONFIG_FILE);
216
+ const userCfg = await readUserConfig(userFile);
217
+
218
+ const { values, sources } = resolveAnswers(ctx, existing, userCfg, parsed.given);
219
+ if (interactive) {
220
+ ui = deps.ui ?? /** @type {any} */ (await import('@clack/prompts'));
221
+ await askAnswers(values, sources, ctx, ui);
222
+ }
223
+
224
+ // Step 5 — where the Jev key comes from. Only a reference is ever kept: `env:NAME` when the
225
+ // environment holds it, else `user` (the key store holds it, whatever backend the store
226
+ // reports — keychain, file, a cached 1Password read…); the backend name is recorded as the
227
+ // user-level `keys.jev.source`.
228
+ /** @type {string[]} */
229
+ const missing = [];
230
+ let jevRef = null;
231
+ let jevSource = 'none';
232
+ if (values.jev.mode === 'ref') {
233
+ jevRef = values.jev.ref;
234
+ jevSource = jevRef.startsWith('env:') ? 'env' : jevRef.startsWith('op://') ? 'op' : 'keychain';
235
+ } else if (values.jev.mode === 'auto') {
236
+ const res = await resolveKey('jev', { store: await getStore(), env });
237
+ if (res.value !== null && typeof res.source === 'string') {
238
+ jevRef = res.source === 'env' ? `env:${JEV_ENV_NAME}` : 'user';
239
+ jevSource = res.source;
240
+ } else if (interactive) {
241
+ ({ ref: jevRef, source: jevSource } = await askJevSource(ui, getStore));
242
+ } else {
243
+ missing.push('keys.jev (pass --jev-ref, --jev-env or --no-jev)');
244
+ }
245
+ }
246
+ if (values.multimodel && !values.second_provider) missing.push('review.second_provider (pass --second-provider)');
247
+ if (missing.length > 0) throw new InitFailure(2, `missing required answers, nothing written:\n${missing.map((m) => ` ${m}`).join('\n')}`);
248
+ say(`keys: jev from ${jevSource}`);
249
+
250
+ // Step 4 — consensus needs three families (§4.4; validator rules 6 and 7). A flag-supplied or
251
+ // kept second provider equal to the effective L2 provider is refused; a judge that shares a
252
+ // provider with a reviewer moves to the third provider's default L3 unless `--level L3` pinned it.
253
+ if (values.multimodel) {
254
+ const reviewer1 = effectiveProvider(values, 'L2');
255
+ if (values.second_provider === reviewer1) {
256
+ throw new InitFailure(2, `review.second_provider equals the effective L2 provider (${reviewer1}) — multimodel needs two providers; pass --second-provider with another one. Nothing written.`);
257
+ }
258
+ const collision = judgeCollision(values);
259
+ if (collision) {
260
+ const l3Flagged = parsed.given.some((g) => g.flag === '--level' && /** @type {any} */ (g.value).level === 'L3');
261
+ if (l3Flagged || collision.third === null) {
262
+ throw new InitFailure(2, `--level L3 pins the judge to ${collision.judge}, a reviewer's provider — the consensus judge must come from a third provider${collision.third ? ` (${collision.third})` : ''}; change or drop --level L3. Nothing written.`);
263
+ }
264
+ values.levels.L3 = proposeJudge(collision.third);
265
+ say(`levels.L3: judge proposed from ${collision.third} (consensus needs a third provider; reviewers ${collision.reviewers.join(', ')})`);
266
+ }
267
+ }
268
+
269
+ const merged = mergeConfig(existing, generatedConfig(values, ctx, jevRef), { dropJev: jevRef === null });
270
+ const checked = validateConfig(merged);
271
+ if (!checked.valid) {
272
+ throw new InitFailure(1, `the resulting config is invalid, nothing written:\n${checked.errors.map((e) => ` ${e.rule}: ${e.message}`).join('\n')}`);
273
+ }
274
+ for (const w of checked.warnings) say(`config warning: ${w.rule}`);
275
+
276
+ const changed = existing ? changedPaths(existing, merged) : [];
277
+ if (existing && changed.length === 0) {
278
+ say(`${DEFAULT_CONFIG_FILENAME}: unchanged`);
279
+ } else {
280
+ if (existing) say(`${DEFAULT_CONFIG_FILENAME}: changes ${changed.join(', ')}`);
281
+ const go = existing && interactive ? await ui.confirm({ message: `Apply ${changed.length} change(s) to ${DEFAULT_CONFIG_FILENAME}?`, initialValue: true }) : true;
282
+ if (ui?.isCancel(go) || go !== true) throw new CancelledError('cancelled — nothing written');
283
+ await writeText(configFile, serializeConfig(merged));
284
+ wrote.push(configFile);
285
+ say(`${DEFAULT_CONFIG_FILENAME}: written`);
286
+ }
287
+
288
+ // From here on the config is on disk: a throw below still reaches `finish` with `wrote` filled.
289
+
290
+ // Step 1 — tools.
291
+ if (values.tools === 'recommended') {
292
+ for (const tool of TOOLS) {
293
+ const present = tool.command === null ? ctx.solo : await commandOnPath(tool.command, { pathEnv: env.PATH ?? '' });
294
+ if (present) {
295
+ say(`tool ${tool.id}: present`);
296
+ } else if (tool.install && values.yes_tools.includes(tool.id)) {
297
+ const res = await (deps.installTool ?? ((argv) => exec(argv, { timeoutMs: 600_000, env })))(tool.install);
298
+ say(`tool ${tool.id}: ${res.result === 'ok' ? 'installed' : 'install failed'} (${tool.install.join(' ')})`);
299
+ } else {
300
+ say(`tool ${tool.id}: missing — ${tool.install ? `install with: ${tool.install.join(' ')}` : tool.hint} (doctor WARNs)`);
301
+ }
302
+ }
303
+ }
304
+
305
+ // Step 3 — optional model-id refresh from the CLI caches (C15).
306
+ if (values.refresh_models) {
307
+ const res = await refreshFromCliCaches({ home });
308
+ say(`models: ${res.sourcesRead.length} CLI cache(s) read`);
309
+ }
310
+
311
+ // Step 2 — harness links.
312
+ const source = deps.skillSource ?? DEFAULT_SKILL_SOURCE;
313
+ if (values.harnesses.length > 0 && !existsSync(source)) {
314
+ say(`links: skill source missing (${source}); no harness linked`);
315
+ } else {
316
+ for (const id of values.harnesses) {
317
+ const harness = getHarness(id);
318
+ const target = values.scope === 'project' ? projectSkillPath(harness, cwd) : globalSkillPath(harness, home);
319
+ await install({ installsFile: installsPath(home), harness: id, scope: values.scope, method: values.method, source, target });
320
+ linked.push(id);
321
+ wrote.push(target);
322
+ say(`link ${id}: ${values.method} ${target}`);
323
+ }
324
+ }
325
+
326
+ // User-level answers: tools, where the Jev key lives, the Solo project.
327
+ /** @type {Record<string, any>} */
328
+ const userOver = { tools: values.tools, keys: { jev: { source: jevSource } } };
329
+ if (values.solo_project !== null) userOver.solo = { project_id: values.solo_project };
330
+ const userNext = mergeInto(userCfg ?? {}, userOver);
331
+ if (changedPaths(userCfg ?? {}, userNext).length > 0) {
332
+ await writeText(userFile, serializeConfig(userNext));
333
+ wrote.push(userFile);
334
+ }
335
+
336
+ // Step 6 — no engine at all: say so now, at setup (§5.1).
337
+ if (ctx.current.agent && !SUBAGENT_HARNESSES.includes(ctx.current.harness) && !ctx.solo && (merged.engine === 'auto' || merged.engine === 'harness')) {
338
+ engineStop = true;
339
+ stderr.write(`${NO_ENGINE_STOP_TEXT}\n`);
340
+ say('engine: no Solo and no subagent tool in this harness (see the stop text)');
341
+ } else {
342
+ say(`engine: ${merged.engine}`);
343
+ }
344
+
345
+ // Step 9 — doctor.
346
+ if (values.doctor) {
347
+ const rows = await (deps.doctor ?? quickDoctor)({ cwd, env });
348
+ const counts = { OK: 0, WARN: 0, FAIL: 0, INFO: 0 };
349
+ for (const r of rows) {
350
+ counts[r.status] = (counts[r.status] ?? 0) + 1;
351
+ say(`doctor ${r.status} ${r.label}: ${r.detail}`);
352
+ }
353
+ doctor = { ok: counts.FAIL === 0, counts };
354
+ }
355
+
356
+ return await finish(doctor === null || doctor.ok ? 0 : 1, null);
357
+ } catch (err) {
358
+ if (err instanceof InitFailure) return finish(err.code, err.message);
359
+ if (err instanceof UsageError) return finish(2, err.message);
360
+ if (err instanceof CancelledError) return finish(1, err.message);
361
+ // an unexpected throw (an installer, a link, the doctor…): the log keeps the stack, redacted
362
+ const reason = err instanceof Error ? err.message : String(err);
363
+ if (err instanceof Error && err.stack) say(redact(err.stack));
364
+ return finish(1, `unexpected error: ${reason}`);
365
+ }
366
+ }