@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,108 @@
1
+ /**
2
+ * `code-forge validate [--file <path>]` — loads and validates a project's `.code-forge.yml`
3
+ * (plan §1.3). Exit 0 when the config is schema-and-domain valid (warnings may still print), exit
4
+ * 1 when it fails to load (missing/unparseable/unmigratable) or fails validation, exit 2 on a
5
+ * misused flag.
6
+ */
7
+
8
+ import { existsSync } from 'node:fs';
9
+ import path from 'node:path';
10
+ import { loadConfigFile, DEFAULT_CONFIG_FILENAME } from '../config/load.mjs';
11
+ import { loadSeenInCache } from '../config/refresh.mjs';
12
+ import { maskSecretTokens } from '../config/secret-patterns.mjs';
13
+ import { validateConfig } from '../config/validate.mjs';
14
+ import * as log from '../util/log.mjs';
15
+
16
+ /** Provider -> the CLI binary name `engine: subprocess` would spawn for it. */
17
+ const PROVIDER_CLI_NAMES = Object.freeze({ anthropic: 'claude', openai: 'codex', xai: 'grok' });
18
+
19
+ /**
20
+ * @param {string} provider
21
+ * @returns {boolean}
22
+ */
23
+ export function hasCliOnPath(provider) {
24
+ const cliName = PROVIDER_CLI_NAMES[/** @type {keyof typeof PROVIDER_CLI_NAMES} */ (provider)];
25
+ if (!cliName) {
26
+ return false;
27
+ }
28
+ const candidates = process.platform === 'win32' ? [`${cliName}.exe`, `${cliName}.cmd`, cliName] : [cliName];
29
+ const dirs = (process.env.PATH ?? '').split(path.delimiter).filter(Boolean);
30
+ return dirs.some((dir) => candidates.some((name) => existsSync(path.join(dir, name))));
31
+ }
32
+
33
+ /**
34
+ * @param {string[]} args
35
+ * @returns {{file: string} | {error: string}}
36
+ */
37
+ function parseArgs(args) {
38
+ let file = path.join(process.cwd(), DEFAULT_CONFIG_FILENAME);
39
+ for (let i = 0; i < args.length; i += 1) {
40
+ if (args[i] === '--file') {
41
+ const value = args[i + 1];
42
+ // A missing value OR one that looks like another flag (`validate --file --foo`) must be a
43
+ // usage error (exit 2), never silently treated as the path "--foo" and reported as a load
44
+ // failure (exit 1) — MINOR §25/26 fix.
45
+ if (!value || value.startsWith('--')) {
46
+ return { error: '--file requires a path argument' };
47
+ }
48
+ file = path.resolve(value);
49
+ i += 1;
50
+ } else {
51
+ return { error: `unknown argument: ${args[i]}` };
52
+ }
53
+ }
54
+ return { file };
55
+ }
56
+
57
+ /**
58
+ * @param {string[]} args
59
+ * @returns {Promise<number>}
60
+ */
61
+ export default async function validate(args) {
62
+ const parsed = parseArgs(args);
63
+ if ('error' in parsed) {
64
+ log.error(maskSecretTokens(parsed.error));
65
+ return 2;
66
+ }
67
+
68
+ const loaded = await loadConfigFile(parsed.file);
69
+ if (!loaded.ok) {
70
+ log.error(maskSecretTokens(`${loaded.error}: ${loaded.message}`));
71
+ return 1;
72
+ }
73
+
74
+ // Both guarded explicitly (MINOR §27/28): a corrupt user-level cache or a validator crash must
75
+ // report the documented exit 1 with a clear cause, not an unhandled rejection whose only
76
+ // diagnostic is whatever the router's generic catch-all prints for a raw stack trace.
77
+ let seenInCache;
78
+ try {
79
+ seenInCache = await loadSeenInCache();
80
+ } catch (err) {
81
+ log.error(maskSecretTokens(`could not read the seen-in-cache file: ${/** @type {Error} */ (err).message}`));
82
+ return 1;
83
+ }
84
+
85
+ let result;
86
+ try {
87
+ result = validateConfig(loaded.config, { seenInCache, hasCliOnPath });
88
+ } catch (err) {
89
+ log.error(maskSecretTokens(`validator crashed: ${/** @type {Error} */ (err).message}`));
90
+ return 1;
91
+ }
92
+
93
+ // `validateConfig` already scrubs every message; masking again here keeps the verb safe even if
94
+ // a future rule regresses (every line also passes B0's `redact` inside `log`).
95
+ for (const err of result.errors) {
96
+ log.error(maskSecretTokens(`[${err.rule}] ${err.message}`));
97
+ }
98
+ for (const warn of result.warnings) {
99
+ log.warn(maskSecretTokens(`[${warn.rule}] ${warn.message}`));
100
+ }
101
+
102
+ if (result.valid) {
103
+ log.info(`${parsed.file}: valid (${result.warnings.length} warning${result.warnings.length === 1 ? '' : 's'})`);
104
+ return 0;
105
+ }
106
+ log.info(`${parsed.file}: invalid (${result.errors.length} error${result.errors.length === 1 ? '' : 's'})`);
107
+ return 1;
108
+ }
@@ -0,0 +1,18 @@
1
+ /** `code-forge version` (also reached via `--version`/`-v`, aliased by `bin/code-forge.mjs`). */
2
+
3
+ import { readFile } from 'node:fs/promises';
4
+ import path from 'node:path';
5
+ import { fileURLToPath } from 'node:url';
6
+
7
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
8
+ const PACKAGE_JSON_PATH = path.join(__dirname, '..', '..', 'package.json');
9
+
10
+ /**
11
+ * @param {string[]} _args
12
+ * @returns {Promise<number>}
13
+ */
14
+ export default async function version(_args) {
15
+ const pkg = JSON.parse(await readFile(PACKAGE_JSON_PATH, 'utf8'));
16
+ process.stdout.write(`${pkg.name} ${pkg.version}\n`);
17
+ return 0;
18
+ }
@@ -0,0 +1,76 @@
1
+ /**
2
+ * `code-forge worker --run <id> [--cwd <dir>] [--poll-ms <n>] [--once]` (plan §4.8; block B11).
3
+ *
4
+ * Serves the run's review queue until SIGTERM/SIGINT (`--once`: drain the queue once and exit).
5
+ * Started detached by the orchestrator (`run start`), never by a coder — `worker` is on the
6
+ * forbidden list (§8.4 ★). Prints one status line on stderr; never a key.
7
+ * Exit codes: 0 stopped cleanly, 1 refused (no run, run ended, another worker, no config), 2 usage.
8
+ */
9
+
10
+ import { bootWorker } from '../worker/loop.mjs';
11
+ import { intFlag, parseFlags } from '../state/cli-args.mjs';
12
+ import { StateError } from '../state/paths.mjs';
13
+ import { writeSafe } from '../util/redact.mjs';
14
+
15
+ const USAGE = 'usage: code-forge worker --run <id> [--cwd <dir>] [--poll-ms <n>] [--once]\n';
16
+
17
+ /**
18
+ * @param {string[]} args
19
+ * @param {import('../worker/loop.mjs').WorkerDeps & {stderr?: {write: (s: string) => unknown}}} [deps]
20
+ * @returns {Promise<number>}
21
+ */
22
+ export async function runWorker(args, deps = {}) {
23
+ const stderr = deps.stderr ?? process.stderr;
24
+ let parsed;
25
+ try {
26
+ parsed = parseFlags(args, { values: ['run', 'cwd', 'poll-ms'], booleans: ['once'] });
27
+ } catch (err) {
28
+ writeSafe(stderr, `worker: ${err.message}\n${USAGE}`);
29
+ return 2;
30
+ }
31
+ const { flags, positionals } = parsed;
32
+ if (positionals.length > 0 || typeof flags.run !== 'string') {
33
+ writeSafe(stderr, USAGE);
34
+ return 2;
35
+ }
36
+ let worker;
37
+ try {
38
+ const pollMs = intFlag(flags['poll-ms'], 'poll-ms');
39
+ worker = await bootWorker({ runId: flags.run, cwd: typeof flags.cwd === 'string' ? flags.cwd : undefined, pollMs }, deps);
40
+ } catch (err) {
41
+ writeSafe(stderr, `worker: ${err?.message ?? String(err)}\n`);
42
+ return err instanceof StateError && err.code === 'usage' ? 2 : 1;
43
+ }
44
+ // A failure inside the loop is reported as one redacted line (`writeSafe`) with exit 1, never as
45
+ // an unhandled rejection whose raw stack could carry a key.
46
+ if (flags.once) {
47
+ try {
48
+ const done = await worker.once(); // announces, drains, then always stops and releases
49
+ writeSafe(stderr, `worker: drained ${done} ticket(s)\n`);
50
+ return 0;
51
+ } catch (err) {
52
+ writeSafe(stderr, `worker: ${err?.message ?? String(err)}\n`);
53
+ return 1;
54
+ }
55
+ }
56
+ const stop = () => worker.stop();
57
+ process.on('SIGTERM', stop);
58
+ process.on('SIGINT', stop);
59
+ writeSafe(stderr, `worker: pid ${process.pid} serving run ${flags.run} · jev key ${worker.jevKeyResolved ? 'resolved' : 'absent (fallback)'}\n`);
60
+ try {
61
+ await worker.run();
62
+ } catch (err) {
63
+ writeSafe(stderr, `worker: ${err?.message ?? String(err)}\n`);
64
+ return 1;
65
+ } finally {
66
+ process.removeListener('SIGTERM', stop);
67
+ process.removeListener('SIGINT', stop);
68
+ }
69
+ writeSafe(stderr, 'worker: stopped\n');
70
+ return 0;
71
+ }
72
+
73
+ /** @param {string[]} args @returns {Promise<number>} */
74
+ export default async function run(args) {
75
+ return runWorker(args);
76
+ }
@@ -0,0 +1,18 @@
1
+ /**
2
+ * Default level matrix for `provider: anthropic` (plan brief §3, 2026-09-24). Effort is omitted
3
+ * where the brief's matrix leaves the cell blank — the Claude Agent tool takes no effort anyway
4
+ * (plan §5.2: "`effort` cannot be passed to the Agent tool"). `caps.coders` (v1.3/B1.1, R10:
5
+ * "at most 2 coders at a time") is not provider-specific, but is carried on every provider default
6
+ * so the `init` wizard writes it regardless of which provider a project picks.
7
+ * @type {{provider: "anthropic", levels: Record<"L0"|"L1"|"L2"|"L3", {model: string}>, caps: {coders: number}}}
8
+ */
9
+ export default Object.freeze({
10
+ provider: 'anthropic',
11
+ levels: Object.freeze({
12
+ L0: Object.freeze({ model: 'claude-haiku-4-5-20251001' }),
13
+ L1: Object.freeze({ model: 'claude-sonnet-5' }),
14
+ L2: Object.freeze({ model: 'claude-opus-5-5' }),
15
+ L3: Object.freeze({ model: 'claude-fable-5-1' }),
16
+ }),
17
+ caps: Object.freeze({ coders: 2 }),
18
+ });
@@ -0,0 +1,24 @@
1
+ /**
2
+ * One lookup table over the three per-provider default matrices, so `load.mjs` (and anything
3
+ * else that needs "the shipped defaults for provider X") imports one thing instead of three.
4
+ */
5
+ import anthropic from './anthropic.mjs';
6
+ import openai from './openai.mjs';
7
+ import xai from './xai.mjs';
8
+
9
+ /** @type {Record<"anthropic" | "openai" | "xai", {provider: string, levels: object, caps: {coders: number}}>} */
10
+ export const PROVIDER_DEFAULTS = Object.freeze({ anthropic, openai, xai });
11
+
12
+ /**
13
+ * `Object.hasOwn`-guarded (fix round 1, MINOR §31/32): an unguarded `PROVIDER_DEFAULTS[provider]`
14
+ * walks the prototype chain, so `provider === 'constructor'` would return the `Object` function
15
+ * (truthy, with no `.levels`) instead of `undefined` — a caller a few frames up the loader might
16
+ * treat that as "found the defaults" and hand back a levels-less object.
17
+ * @param {string} provider
18
+ * @returns {{provider: string, levels: object, caps: {coders: number}} | undefined}
19
+ */
20
+ export function defaultsForProvider(provider) {
21
+ return typeof provider === 'string' && Object.hasOwn(PROVIDER_DEFAULTS, provider)
22
+ ? PROVIDER_DEFAULTS[/** @type {"anthropic" | "openai" | "xai"} */ (provider)]
23
+ : undefined;
24
+ }
@@ -0,0 +1,16 @@
1
+ /**
2
+ * Default level matrix for `provider: openai` (Codex CLI; plan brief §3, 2026-09-24). `caps.coders`
3
+ * (v1.3/B1.1, R10: "at most 2 coders at a time") is not provider-specific, but is carried on every
4
+ * provider default so the `init` wizard writes it regardless of which provider a project picks.
5
+ * @type {{provider: "openai", levels: Record<"L0"|"L1"|"L2"|"L3", {model: string, effort: string}>, caps: {coders: number}}}
6
+ */
7
+ export default Object.freeze({
8
+ provider: 'openai',
9
+ levels: Object.freeze({
10
+ L0: Object.freeze({ model: 'gpt-6-luna', effort: 'low' }),
11
+ L1: Object.freeze({ model: 'gpt-6-luna', effort: 'high' }),
12
+ L2: Object.freeze({ model: 'gpt-6-sol', effort: 'high' }),
13
+ L3: Object.freeze({ model: 'gpt-6-astra', effort: 'high' }),
14
+ }),
15
+ caps: Object.freeze({ coders: 2 }),
16
+ });
@@ -0,0 +1,18 @@
1
+ /**
2
+ * Default level matrix for `provider: xai` (Grok CLI; plan brief §3, 2026-09-24). xAI has no
3
+ * cheap small model, so L0 uses the same `grok-4.7` id as every other level, at `low` effort.
4
+ * `caps.coders` (v1.3/B1.1, R10: "at most 2 coders at a time") is not provider-specific, but is
5
+ * carried on every provider default so the `init` wizard writes it regardless of which provider a
6
+ * project picks.
7
+ * @type {{provider: "xai", levels: Record<"L0"|"L1"|"L2"|"L3", {model: string, effort: string}>, caps: {coders: number}}}
8
+ */
9
+ export default Object.freeze({
10
+ provider: 'xai',
11
+ levels: Object.freeze({
12
+ L0: Object.freeze({ model: 'grok-4.7', effort: 'low' }),
13
+ L1: Object.freeze({ model: 'grok-4.7', effort: 'medium' }),
14
+ L2: Object.freeze({ model: 'grok-4.7', effort: 'high' }),
15
+ L3: Object.freeze({ model: 'grok-4.7', effort: 'xhigh' }),
16
+ }),
17
+ caps: Object.freeze({ coders: 2 }),
18
+ });
@@ -0,0 +1,171 @@
1
+ /**
2
+ * The model catalog and the model-id lint set (plan §1.3, §1.4, C1).
3
+ *
4
+ * Two independent jobs live here on purpose:
5
+ * 1. `KNOWN_IDS` / `knownIdsForProvider()` — the "is this a real model id" catalog `validate.mjs`
6
+ * checks a `levels.Lx.model` / `levels.Lx.fallback[].model` value against.
7
+ * 2. `LINT_PATTERNS` — the pattern set `test/skill/lint-no-model-ids.test.mjs` (B14, a later
8
+ * block) scans `skill/**` with, so no model id or alias ever leaks into the shipped skill
9
+ * prose. C1's fix for the old bare-token list (`sol`, `fable`, …) matching "Solo", "resolve",
10
+ * "console", "fable-forge": every pattern here is either a full catalog id (escaped literally
11
+ * — can't false-positive on an unrelated word) or requires a vendor-prefixed shape
12
+ * (`claude-9`, not bare `claude`) or a `--model <alias>` / `model: <alias>` value position
13
+ * (not a bare alias word appearing in prose).
14
+ *
15
+ * `resolveLevel()` is the third thing this module exports: a pure function (no I/O, no CLI
16
+ * spawning) that answers "what provider/model/effort does level Lx actually resolve to", given a
17
+ * loaded config — the per-level `provider` override (R5) wins over the top-level `provider`, and
18
+ * the id/effort always come from `levels.Lx` (config is the only source; this module never
19
+ * invents a default id for a level the config didn't set — that is `defaults/*.mjs`'s job, merged
20
+ * into the config by `load.mjs` before anything calls `resolveLevel`).
21
+ */
22
+
23
+ /**
24
+ * @typedef {"anthropic" | "openai" | "xai"} Provider
25
+ */
26
+
27
+ /**
28
+ * The full catalog, by provider, as read from each CLI's own model cache on this Mac
29
+ * (plan brief §3, 2026-09-24; A8). Four Anthropic ids, three OpenAI ids, four xAI ids — eleven in
30
+ * total, matching the "≥ 11 ids" the acceptance clause counts.
31
+ * @type {Record<Provider, ReadonlyArray<string>>}
32
+ */
33
+ export const KNOWN_IDS = Object.freeze({
34
+ anthropic: Object.freeze([
35
+ 'claude-haiku-4-5-20251001',
36
+ 'claude-sonnet-5',
37
+ 'claude-opus-5-5',
38
+ 'claude-fable-5-1',
39
+ ]),
40
+ openai: Object.freeze(['gpt-6-luna', 'gpt-6-sol', 'gpt-6-astra']),
41
+ xai: Object.freeze(['grok-4.7', 'grok-4.7-build-fast', 'grok-4.6', 'grok-4.5']),
42
+ });
43
+
44
+ /** Every catalog id, flattened, in a stable (provider-then-declaration) order. */
45
+ export const ALL_KNOWN_IDS = Object.freeze(Object.values(KNOWN_IDS).flat());
46
+
47
+ /** The frozen empty array every "no ids for this provider" path returns — one shared instance. */
48
+ const EMPTY_IDS = Object.freeze([]);
49
+
50
+ /**
51
+ * `Object.hasOwn`-guarded, not a bare `KNOWN_IDS[provider]` index: an UNGUARDED lookup walks the
52
+ * prototype chain, so `provider === 'constructor'` (or `'toString'`, `'__proto__'`, …) would
53
+ * return a built-in `Object` member instead of `undefined` — a config-supplied string reaching
54
+ * this before the schema's `enum` has rejected it (e.g. inside a still-being-validated domain
55
+ * rule) must never resolve to a function.
56
+ * @param {string} provider
57
+ * @returns {ReadonlyArray<string>} the catalog for `provider`, or an empty array for an unknown one.
58
+ */
59
+ export function knownIdsForProvider(provider) {
60
+ return typeof provider === 'string' && Object.hasOwn(KNOWN_IDS, provider)
61
+ ? KNOWN_IDS[/** @type {Provider} */ (provider)]
62
+ : EMPTY_IDS;
63
+ }
64
+
65
+ /**
66
+ * @param {string} provider
67
+ * @param {string} model
68
+ * @returns {boolean}
69
+ */
70
+ export function isKnownId(provider, model) {
71
+ return knownIdsForProvider(provider).includes(model);
72
+ }
73
+
74
+ /**
75
+ * Vendor prefixes the lint also flags even for an id NOT in the catalog yet (a future release).
76
+ * Each pattern requires a DIGIT to appear within the SAME hyphen-word run right after the
77
+ * vendor's own hyphen — `<vendor>-<letters>?-?<digit>` — never a bare `\bclaude-[0-9a-z]`, which
78
+ * also matched ordinary prose like "Claude-compatible", "GPT-style", "grok-cli", "claude-code"
79
+ * (fix round 1, MAJOR §35/36 in the review). An earlier tightened draft enumerated only the four
80
+ * KNOWN family words (opus/sonnet/haiku/fable) — that excluded the false positives too, but also
81
+ * defeated this module's own stated purpose ("flags even for an id NOT in the catalog yet"): a
82
+ * genuinely NEW family name (`claude-nova-9`) would have gone unflagged. `[a-z]*` accepts ANY
83
+ * family word (or none), so the pattern stays open to a future release while still requiring the
84
+ * digit that separates a real id from ordinary hyphenated prose.
85
+ * @type {ReadonlyArray<RegExp>}
86
+ */
87
+ const VENDOR_PREFIX_PATTERNS = Object.freeze([
88
+ /\bclaude-[a-z]*-?\d/i,
89
+ /\bgpt-[a-z]*-?\d/i,
90
+ /\bgrok-[a-z]*-?\d/i,
91
+ /\bgemini-[a-z]*-?\d/i,
92
+ ]);
93
+
94
+ /**
95
+ * Escape every regex metacharacter in `text` so it can be embedded literally inside a larger
96
+ * pattern (a raw catalog id like `grok-4.7` contains a `.`, which is "any character" unescaped).
97
+ * @param {string} text
98
+ * @returns {string}
99
+ */
100
+ function escapeRegExp(text) {
101
+ return text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
102
+ }
103
+
104
+ /** Aliases short enough to appear as a bare word in prose — only flagged in VALUE position (C1). */
105
+ const VALUE_POSITION_ALIASES = Object.freeze(['opus', 'sonnet', 'haiku', 'fable', 'luna', 'sol', 'astra']);
106
+
107
+ /**
108
+ * The lint pattern set (C1): every catalog id, escaped literally, as its own whole-word pattern;
109
+ * one pattern per vendor prefix (`\bclaude-[0-9a-z]`, …); one pattern for an alias that only
110
+ * appears after `--model`/`model:` (so "Solo", "resolve", "console", "fable-forge", "Jev" — none
111
+ * of which are `--model <alias>` — stay at 0 hits, the positive-control requirement B14's test
112
+ * carries over from this module).
113
+ *
114
+ * `>= 11` catalog patterns + `4` vendor-prefix patterns, both counted by B14's test against this
115
+ * export directly (not a hardcoded copy), so the set can never silently go stale.
116
+ * @type {ReadonlyArray<RegExp>}
117
+ */
118
+ export const LINT_PATTERNS = Object.freeze([
119
+ ...ALL_KNOWN_IDS.map((id) => new RegExp(`\\b${escapeRegExp(id)}\\b`)),
120
+ ...VENDOR_PREFIX_PATTERNS,
121
+ // `--model` requires an ACTUAL separator (`=` or whitespace) before the alias — the old bare
122
+ // `\s*` (zero-or-more) let `=` slip through unconsumed, so `--model=opus` never matched
123
+ // (fix round 1, MINOR §37/38). The `model:` YAML-key form keeps `\s*` (zero-or-more): a bare
124
+ // `model:"opus"` with no space is still a real value-position use.
125
+ new RegExp(`(?:--model[=\\s]+|model:\\s*)["']?(?:${VALUE_POSITION_ALIASES.join('|')})\\b`, 'i'),
126
+ ]);
127
+
128
+ /**
129
+ * @typedef {object} ResolvedLevel
130
+ * @property {Provider} provider - the per-level `provider` override when set, else the top-level `provider`.
131
+ * @property {string} model
132
+ * @property {string | undefined} effort
133
+ * @property {ReadonlyArray<{provider: string, model: string, effort?: string}>} fallback - left as
134
+ * plain `string` (not the `Provider` union) on purpose: a fallback entry comes straight from the
135
+ * user's config and isn't narrowed/validated by this pure function — that's `validate.mjs`'s job.
136
+ */
137
+
138
+ /**
139
+ * Pure: resolves level `levelName` (`"L0"`..`"L3"`) from an already-loaded config object. Never
140
+ * reads a file, never spawns a process, never applies a provider default the config itself didn't
141
+ * set — `load.mjs` is responsible for merging `defaults/*.mjs` into `cfg` before this is called.
142
+ *
143
+ * @param {Record<string, any>} cfg - shaped like `{provider?, levels: {L0..L3: {model, effort?,
144
+ * provider?, fallback?}}}`, loosely typed so a hand-built (possibly partial/malformed) test
145
+ * fixture and a real loaded config both type-check the same way.
146
+ * @param {"L0" | "L1" | "L2" | "L3"} levelName
147
+ * @returns {ResolvedLevel}
148
+ * @throws {Error} when `cfg.levels[levelName]` is missing or has no `model` — resolving an absent
149
+ * level is a caller bug (the schema already requires all four; a hand-built `cfg` in a test
150
+ * that skips validation is the only way to hit this).
151
+ * @throws {Error} when neither the level's own `provider` nor the top-level `cfg.provider` is set
152
+ * — resolving to `provider: undefined` would hand every caller a `ResolvedLevel` whose declared
153
+ * type (`Provider`) it doesn't actually satisfy (MINOR §39/40 fix).
154
+ */
155
+ export function resolveLevel(cfg, levelName) {
156
+ const level = cfg?.levels?.[levelName];
157
+ if (!level || typeof level.model !== 'string' || level.model.length === 0) {
158
+ throw new Error(`resolveLevel: levels.${levelName} is missing or has no model`);
159
+ }
160
+ const rawProvider = level.provider ?? cfg?.provider;
161
+ if (typeof rawProvider !== 'string' || rawProvider.length === 0) {
162
+ throw new Error(`resolveLevel: levels.${levelName} has no provider (neither per-level nor top-level)`);
163
+ }
164
+ const provider = /** @type {Provider} */ (rawProvider);
165
+ return Object.freeze({
166
+ provider,
167
+ model: level.model,
168
+ effort: level.effort,
169
+ fallback: Object.freeze((level.fallback ?? []).map((f) => Object.freeze({ ...f }))),
170
+ });
171
+ }
@@ -0,0 +1,135 @@
1
+ /**
2
+ * The project config loader (plan §1.2 `load.mjs`). Reads `.code-forge.yml`, parses it, and runs
3
+ * it through {@link migrateConfig} — nothing more. It deliberately does NOT validate (that is
4
+ * `validate.mjs`, a separate concern so a caller can load-then-inspect a config that fails
5
+ * validation instead of getting only a thrown error) and does NOT merge in provider defaults
6
+ * (those are only ever written into the file by the `init` wizard, a later block — a config on
7
+ * disk is expected to already be complete).
8
+ *
9
+ * **Secret safety (fix round 1, MAJOR):** the `yaml` package's default `parse()` builds its
10
+ * `YAMLParseError.message` WITH a code-frame excerpt of the offending source line baked in — so a
11
+ * malformed `.code-forge.yml` with a secret-looking value on or near the bad line (`api_key: sk-
12
+ * ...`) would put that value straight into `result.message`, which every CLI caller prints
13
+ * (`src/cli/validate.mjs`). This module parses with `{prettyErrors: false}` (message text only,
14
+ * no excerpt) and builds its own `line, column` location from the error's raw character offset
15
+ * (`err.pos`) and the source string — never from anything the `yaml` package formats for display.
16
+ */
17
+
18
+ import { readFile } from 'node:fs/promises';
19
+ import path from 'node:path';
20
+ import { parse as parseYAML } from 'yaml';
21
+ import { migrateConfig } from './migrate.mjs';
22
+ import { maskSecretTokens } from './secret-patterns.mjs';
23
+
24
+ /** The config file's conventional name at a project's root. */
25
+ export const DEFAULT_CONFIG_FILENAME = '.code-forge.yml';
26
+
27
+ /**
28
+ * @typedef {object} LoadResult
29
+ * @property {boolean} ok
30
+ * @property {string} path - the resolved file path this result is about.
31
+ * @property {Record<string, any>} [config] - present when `ok`.
32
+ * @property {"not-found" | "read-error" | "parse-error" | "migrate-error"} [error] - present when `!ok`.
33
+ * @property {string} [message] - present when `!ok`.
34
+ */
35
+
36
+ /**
37
+ * Computes a 1-based `{line, column}` from a character offset into `source`, without ever
38
+ * formatting or returning the source text itself.
39
+ * @param {string} source
40
+ * @param {number} offset
41
+ * @returns {{line: number, column: number}}
42
+ */
43
+ function locationAt(source, offset) {
44
+ let line = 1;
45
+ let column = 1;
46
+ const end = Math.min(offset, source.length);
47
+ for (let i = 0; i < end; i += 1) {
48
+ if (source[i] === '\n') {
49
+ line += 1;
50
+ column = 1;
51
+ } else {
52
+ column += 1;
53
+ }
54
+ }
55
+ return { line, column };
56
+ }
57
+
58
+ /**
59
+ * Builds a safe (excerpt-free) message for a `yaml` `YAMLParseError` (or any error shaped like
60
+ * one — `{code, message, pos}`), appending `line, column` when the error carries a position.
61
+ * @param {string} source
62
+ * @param {{code?: string, message: string, pos?: unknown}} err
63
+ * @returns {string}
64
+ */
65
+ function describeYamlError(source, err) {
66
+ const code = err.code ?? 'YAML_PARSE_ERROR';
67
+ // Even with `prettyErrors: false` a few `yaml` messages quote a source token (an alias or tag
68
+ // name) — every secret-shaped token is masked as a second layer (round 3).
69
+ const text = maskSecretTokens(String(err.message));
70
+ const pos = Array.isArray(err.pos) && typeof err.pos[0] === 'number' ? err.pos[0] : undefined;
71
+ if (pos === undefined) {
72
+ return `${code}: ${text}`;
73
+ }
74
+ const { line, column } = locationAt(source, pos);
75
+ return `${code}: ${text} (line ${line}, column ${column})`;
76
+ }
77
+
78
+ /**
79
+ * @param {unknown} parsed
80
+ * @returns {boolean} true when `parsed` is a plain mapping (what a `.code-forge.yml` root must be).
81
+ */
82
+ function isPlainMapping(parsed) {
83
+ return parsed !== null && typeof parsed === 'object' && !Array.isArray(parsed);
84
+ }
85
+
86
+ /**
87
+ * @param {string} filePath - an absolute or cwd-relative path to a `.code-forge.yml`-shaped file.
88
+ * @returns {Promise<LoadResult>}
89
+ */
90
+ export async function loadConfigFile(filePath) {
91
+ let raw;
92
+ try {
93
+ raw = await readFile(filePath, 'utf8');
94
+ } catch (err) {
95
+ const code = /** @type {NodeJS.ErrnoException} */ (err).code;
96
+ if (code === 'ENOENT') {
97
+ return { ok: false, path: filePath, error: 'not-found', message: `no config file at ${filePath}` };
98
+ }
99
+ // A directory, a permission error, etc. — distinct from "not found" so a caller can't
100
+ // mistake "the path exists but can't be read as a file" for "nothing is there yet".
101
+ return {
102
+ ok: false,
103
+ path: filePath,
104
+ error: 'read-error',
105
+ message: `could not read ${filePath}: ${code ?? /** @type {Error} */ (err).message}`,
106
+ };
107
+ }
108
+
109
+ let parsed;
110
+ try {
111
+ parsed = parseYAML(raw, { prettyErrors: false });
112
+ } catch (err) {
113
+ return { ok: false, path: filePath, error: 'parse-error', message: describeYamlError(raw, /** @type {any} */ (err)) };
114
+ }
115
+
116
+ if (!isPlainMapping(parsed)) {
117
+ const kind = parsed === null || parsed === undefined ? 'an empty document' : Array.isArray(parsed) ? 'a list' : typeof parsed;
118
+ return { ok: false, path: filePath, error: 'parse-error', message: `config root must be a mapping, got ${kind}` };
119
+ }
120
+
121
+ try {
122
+ const config = migrateConfig(parsed);
123
+ return { ok: true, path: filePath, config };
124
+ } catch (err) {
125
+ return { ok: false, path: filePath, error: 'migrate-error', message: /** @type {Error} */ (err).message };
126
+ }
127
+ }
128
+
129
+ /**
130
+ * @param {string} [cwd] - defaults to `process.cwd()`.
131
+ * @returns {Promise<LoadResult>}
132
+ */
133
+ export async function loadProjectConfig(cwd = process.cwd()) {
134
+ return loadConfigFile(path.join(cwd, DEFAULT_CONFIG_FILENAME));
135
+ }
@@ -0,0 +1,46 @@
1
+ /**
2
+ * Config version migration (plan §1.2 `migrate.mjs`, §1.3 `version`). Only `version: 1` has ever
3
+ * shipped, so today this is "fill in a missing version and refuse anything it doesn't recognise" —
4
+ * a real, testable function even with one version on record, because it is the seam a future `2`
5
+ * plugs into (add a `case 2:` branch here, never a scatter of `cfg.version === 2` checks across
6
+ * the codebase).
7
+ */
8
+
9
+ /** The newest config version this build of code-forge understands. */
10
+ export const CURRENT_VERSION = 1;
11
+
12
+ /**
13
+ * @param {unknown} raw - the value `yaml.parse()` returned (or a hand-built object in a test).
14
+ * @returns {Record<string, any>} `raw` with `version` normalized to {@link CURRENT_VERSION}.
15
+ * @throws {TypeError} when `raw` isn't a plain mapping (a YAML file that parses to a scalar, an
16
+ * array, or `null`/`undefined` at the top level).
17
+ * @throws {Error} when `raw.version` is missing-but-invalid (not a positive integer), newer than
18
+ * {@link CURRENT_VERSION} (a config written by a future code-forge), or older than any version
19
+ * this build has ever shipped (`< 1`).
20
+ */
21
+ export function migrateConfig(raw) {
22
+ if (raw === null || typeof raw !== 'object' || Array.isArray(raw)) {
23
+ // `typeof raw` alone is 'object' for BOTH null and an array — exactly the two cases this
24
+ // guard exists to catch — so the old message read "expected a mapping... got object" even
25
+ // when the actual problem WAS an object-shaped value (fix round 1, MINOR §45/46).
26
+ const kind = raw === null ? 'null' : Array.isArray(raw) ? 'array' : typeof raw;
27
+ throw new TypeError(`migrateConfig: expected a YAML mapping (object) at the document root, got ${kind}`);
28
+ }
29
+ const mapping = /** @type {Record<string, any>} */ (raw);
30
+
31
+ const version = mapping.version ?? 1;
32
+ if (!Number.isInteger(version) || version < 1) {
33
+ // Names the TYPE, never the value (round 3): `version: <pasted key>` must not be echoed.
34
+ const kind = mapping.version === null ? 'null' : Array.isArray(mapping.version) ? 'array' : typeof mapping.version;
35
+ throw new Error(`migrateConfig: version must be a positive integer, got a ${kind}`);
36
+ }
37
+ if (version > CURRENT_VERSION) {
38
+ throw new Error(
39
+ `migrateConfig: config version ${version} is newer than this code-forge understands (${CURRENT_VERSION}) — upgrade the package`,
40
+ );
41
+ }
42
+
43
+ // version === 1: no structural rewrite needed yet. Future versions add a branch here, applied
44
+ // in order, each one moving the object one version forward before the next branch runs.
45
+ return { ...mapping, version: CURRENT_VERSION };
46
+ }