@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,694 @@
1
+ /**
2
+ * The config validator (plan §1.3 "Validator refusals" + §10.3 B1 acceptance: "14 refusals ⇒ 14
3
+ * tests"). Two independent layers feed one result:
4
+ *
5
+ * 1. **Schema validation** (Ajv, draft 2020-12, `additionalProperties: false` almost everywhere)
6
+ * catches shape errors: unknown keys, wrong types, missing required fields.
7
+ * 2. **Domain rules** (`DOMAIN_RULES` below, 14 of them as of B1.2/v1.3) catch things no JSON Schema can express:
8
+ * cross-field comparisons (two levels resolving to the same tuple), a value that needs a
9
+ * second config value to be reachable (`second_levels.L2.provider` vs. the effective L2
10
+ * provider) and rules that reach outside the config object entirely (is a CLI on PATH, is a
11
+ * model id something a later `--refresh-models` run has seen). Every domain rule is its own
12
+ * named, independently testable function — most take `cfg` alone; two (`fallback-unknown-
13
+ * or-cli-absent`, `engine-subprocess-no-cli`) take an **injected** `hasCliOnPath` function
14
+ * instead of touching `process.env.PATH` themselves, so a unit test never needs a real CLI on
15
+ * PATH to prove either branch.
16
+ *
17
+ * The 14 rules (B1's original 14 plus B1.1's `caps-coders-exceeds-cap`, minus B1.2's removal of
18
+ * `proof-tool-absent-for-high-tier` — Q16 answered "cut", so `proof.tiers.high.tool` no longer
19
+ * exists to be absent, plan §10.4 Wave 6), and how the plan's prose bullets map onto them (§1.3's
20
+ * "review.multimodel: true with second_provider empty **or with**
21
+ * resolve(L2).provider == second_levels.L2.provider" is two independently-firing conditions, so it
22
+ * is implemented — and tested — as two rules):
23
+ * 1 level-missing ERROR
24
+ * 2 unknown-model-id WARN (known_extra / seen-in-cache lift it, C15)
25
+ * 3 duplicate-level-tuple ERROR
26
+ * 4 stop-at-not-l3 ERROR
27
+ * 5 multimodel-missing-second-provider ERROR
28
+ * 6 multimodel-effective-l2-collision ERROR ("effective-L2 consensus rule")
29
+ * 7 judge-family-collision ERROR (consensus mode only)
30
+ * 8 orchestrator-l3-multimodel-off WARN
31
+ * 9 gates-not-argv-array ERROR
32
+ * 10 secret-looking-value ERROR (names the key)
33
+ * 11 fallback-unknown-or-cli-absent WARN ("fallback validation")
34
+ * 12 engine-subprocess-no-cli ERROR
35
+ * 13 shadow-rate-range ERROR ("shadow_rate range")
36
+ * 14 caps-coders-exceeds-cap WARN (v1.3/R10/B1.1: caps.coders > 2)
37
+ *
38
+ * **B1.2 (v1.3, Q16 cut) removed rule:** `proof-tool-absent-for-high-tier` (formerly #13) — the
39
+ * key it warned about (`proof.tiers.high.tool`) left the schema in the same amendment, along with
40
+ * `proof.tiers.high.min_msi`, `proof.js` and `proof.nightly` (the tool-made-mutation-only keys).
41
+ * `proof.tiers.high.paths` alone no longer implies a missing tool, so the rule has nothing left to
42
+ * check. The 15 -> 14 count and the renumbering below (old 14/15 -> new 13/14) are this block's
43
+ * amendment.
44
+ *
45
+ * **No config value ever reaches a message (fix round 3, MAJOR).** The `validate` verb prints
46
+ * every message, and a user can paste an API key into ANY string field — a model id, an effort,
47
+ * a fallback, `escalation.stop_at`. So a message names the key PATH and the rule, never the value
48
+ * found there. The only config-derived words a message may contain are (a) key-path segments,
49
+ * each passed through `safeSegment` (a secret pasted in as a map KEY is masked too), and (b) a
50
+ * provider name, only when it is one of the schema's own closed `$defs.provider.enum` words
51
+ * (`safeProvider`) — anything else prints as `(unrecognised provider)`. As a second layer every
52
+ * message is then scrubbed by `finalizeMessage`: B0's `redact()` (registered secrets) plus a local
53
+ * pass that masks every secret-looking token found anywhere in THIS config, so a future rule that
54
+ * regresses and interpolates a value still cannot print one.
55
+ */
56
+
57
+ // Named import, not default: TypeScript's NodeNext resolution treats ajv's CJS-authored .d.ts as
58
+ // non-constructable through the default-export path (`new (import default)()` fails checkJs with
59
+ // TS2351) — the named class export resolves cleanly under both checkJs and at runtime. `[facts]`
60
+ import { Ajv2020 } from 'ajv/dist/2020.js';
61
+ import schema from '../../schema/code-forge.schema.json' with { type: 'json' };
62
+ import { redact } from '../util/redact.mjs';
63
+ import { isKnownId } from './known-ids.mjs';
64
+ import { looksLikeSecret, maskSecretTokens, SECRET_MASK, secretTokensIn } from './secret-patterns.mjs';
65
+
66
+ /**
67
+ * @typedef {object} ValidationIssue
68
+ * @property {string} rule
69
+ * @property {"error" | "warning"} severity
70
+ * @property {string} message
71
+ * @property {string} [keyword] - schema issues only: the Ajv keyword that failed (`propertyNames`, `type`, …).
72
+ * @property {string} [path] - schema issues only: the (sanitised) JSON pointer of the failing instance.
73
+ */
74
+
75
+ /**
76
+ * @typedef {object} ValidationResult
77
+ * @property {boolean} valid - `false` when at least one ERROR-severity issue was found (schema or domain).
78
+ * @property {ValidationIssue[]} errors
79
+ * @property {ValidationIssue[]} warnings
80
+ */
81
+
82
+ const ajv = new Ajv2020({ allErrors: true, strict: true });
83
+ const ajvValidate = ajv.compile(schema);
84
+
85
+ // ── Secret detection (rule 10, and the message scrub every rule's output passes through) ──
86
+ // The patterns themselves live in `./secret-patterns.mjs` (shared with load/refresh/the verbs).
87
+
88
+ /**
89
+ * Collects every secret-looking string in `cfg` — values AND keys, whole string AND each matched
90
+ * token — for `finalizeMessage`'s local scrub.
91
+ * @param {unknown} node
92
+ * @param {Set<string>} out
93
+ */
94
+ function collectSecretStrings(node, out) {
95
+ const consider = (/** @type {string} */ text) => {
96
+ if (looksLikeSecret(text)) {
97
+ out.add(text);
98
+ for (const token of secretTokensIn(text)) {
99
+ out.add(token);
100
+ }
101
+ }
102
+ };
103
+ if (typeof node === 'string') {
104
+ consider(node);
105
+ return;
106
+ }
107
+ if (Array.isArray(node)) {
108
+ for (const item of node) {
109
+ collectSecretStrings(item, out);
110
+ }
111
+ return;
112
+ }
113
+ if (node !== null && typeof node === 'object') {
114
+ for (const [key, value] of Object.entries(node)) {
115
+ consider(key);
116
+ collectSecretStrings(value, out);
117
+ }
118
+ }
119
+ }
120
+
121
+ /**
122
+ * The second layer every message passes through: B0's `redact()` (secrets a key resolver has
123
+ * registered), then a local mask of every secret-looking string this config contains (longest
124
+ * first, so a whole value is masked before one of its own tokens could split it), then a final
125
+ * shape-based mask of any secret-shaped token still in the text.
126
+ * @param {string} message
127
+ * @param {string[]} secretsLongestFirst
128
+ * @returns {string}
129
+ */
130
+ function finalizeMessage(message, secretsLongestFirst) {
131
+ let out = redact(message);
132
+ for (const secret of secretsLongestFirst) {
133
+ out = out.split(secret).join(SECRET_MASK);
134
+ }
135
+ return maskSecretTokens(out);
136
+ }
137
+
138
+ // ── Safe renderers: the ONLY ways config-derived text may enter a message ──
139
+
140
+ /** The schema's own closed provider vocabulary — a word from here is a constant, not user data. */
141
+ const PROVIDER_NAMES = Object.freeze(/** @type {string[]} */ (schema.$defs.provider.enum));
142
+
143
+ /**
144
+ * @param {unknown} provider
145
+ * @returns {string} `"anthropic"` (quoted) for a known provider, `(unrecognised provider)` otherwise.
146
+ */
147
+ function safeProvider(provider) {
148
+ return typeof provider === 'string' && PROVIDER_NAMES.includes(provider) ? `"${provider}"` : '(unrecognised provider)';
149
+ }
150
+
151
+ /**
152
+ * A key-path segment as it may appear in a message: masked when the KEY itself looks like a secret
153
+ * (a token pasted in as a `keys.<name>` / `known_extra.<provider>` map key), verbatim otherwise.
154
+ * @param {string} segment
155
+ * @returns {string}
156
+ */
157
+ function safeSegment(segment) {
158
+ return looksLikeSecret(segment) ? '<redacted-key>' : segment;
159
+ }
160
+
161
+ /**
162
+ * @param {string} pointer - an Ajv `instancePath` (`/a/b/0`).
163
+ * @returns {string} the same pointer, every segment through `safeSegment`.
164
+ */
165
+ function safePointer(pointer) {
166
+ if (!pointer) {
167
+ return '(root)';
168
+ }
169
+ return pointer
170
+ .split('/')
171
+ .map((segment, i) => (i === 0 ? segment : safeSegment(segment.replace(/~1/g, '/').replace(/~0/g, '~'))))
172
+ .join('/');
173
+ }
174
+
175
+ // ── 0. schema (Ajv) ─────────────────────────────────────────────────────────
176
+
177
+ /**
178
+ * Ajv's own `message` templates never interpolate the instance value (only schema-side words:
179
+ * a type name, an allowed-values list, a limit). The parts that come from the CONFIG are the
180
+ * instance path and, for `propertyNames`/`additionalProperties`, the offending key — each rendered
181
+ * through `safeSegment`. An `enum`/`pattern` error that Ajv emits FOR a property name (it carries
182
+ * `err.propertyName`) is folded into the `propertyNames` error that always follows it, so one bad
183
+ * key produces exactly one issue.
184
+ * @param {Record<string, any>} cfg
185
+ * @returns {ValidationIssue[]}
186
+ */
187
+ function runSchemaValidation(cfg) {
188
+ const ok = ajvValidate(cfg);
189
+ if (ok) {
190
+ return [];
191
+ }
192
+ return (ajvValidate.errors ?? [])
193
+ .filter((err) => /** @type {any} */ (err).propertyName === undefined)
194
+ .map((err) => {
195
+ const where = safePointer(err.instancePath);
196
+ let detail = err.message ?? 'is invalid';
197
+ if (err.keyword === 'propertyNames' && typeof err.params?.propertyName === 'string') {
198
+ detail = `property name "${safeSegment(err.params.propertyName)}" is not allowed (propertyNames)`;
199
+ } else if (err.keyword === 'additionalProperties' && typeof err.params?.additionalProperty === 'string') {
200
+ detail = `must NOT have additional property "${safeSegment(err.params.additionalProperty)}"`;
201
+ }
202
+ return {
203
+ rule: 'schema',
204
+ severity: /** @type {const} */ ('error'),
205
+ keyword: err.keyword,
206
+ path: where,
207
+ message: `${where} ${detail}`,
208
+ };
209
+ });
210
+ }
211
+
212
+ // ── Small, reusable helpers over an already-parsed config object ───────────
213
+
214
+ const LEVEL_NAMES = /** @type {const} */ (['L0', 'L1', 'L2', 'L3']);
215
+
216
+ /**
217
+ * `validateConfig` is documented to accept a config that isn't schema-valid yet (domain rules run
218
+ * even when Ajv already failed it), so every helper below must tolerate a wrongly-shaped value
219
+ * without throwing — the schema layer is what REPORTS a shape violation; a domain rule just needs
220
+ * to not crash on one. `asStringArray` is the one place that trust boundary is enforced: anything
221
+ * that isn't an array of strings becomes `[]` rather than something `.includes()` might throw on
222
+ * (a number) or silently mis-match against (a plain string, which `Set`/`Array` iteration would
223
+ * otherwise split into individual characters).
224
+ * @param {unknown} value
225
+ * @returns {string[]}
226
+ */
227
+ function asStringArray(value) {
228
+ return Array.isArray(value) && value.every((item) => typeof item === 'string') ? value : [];
229
+ }
230
+
231
+ /**
232
+ * An OWN entry of a (possibly hostile) map as a string array — `Object.hasOwn`, never a bare index,
233
+ * so a provider named `constructor`/`__proto__` can't resolve to an inherited member.
234
+ * @param {unknown} map
235
+ * @param {string} key
236
+ * @returns {string[]}
237
+ */
238
+ function ownStringList(map, key) {
239
+ if (!map || typeof map !== 'object' || !Object.hasOwn(map, key)) {
240
+ return [];
241
+ }
242
+ return asStringArray(/** @type {Record<string, unknown>} */ (map)[key]);
243
+ }
244
+
245
+ /**
246
+ * @param {Record<string, any>} cfg
247
+ * @param {"L0"|"L1"|"L2"|"L3"} levelName
248
+ * @returns {{provider: string, model: string, effort: string | undefined} | null} `null` when the
249
+ * level, its model, OR its resolved provider is missing/not-a-string — callers that need a
250
+ * level to exist check `level-missing` first; callers that read `.provider` never see anything
251
+ * but a real string here.
252
+ */
253
+ function effectiveLevel(cfg, levelName) {
254
+ const level = cfg?.levels?.[levelName];
255
+ if (!hasModel(level)) {
256
+ return null;
257
+ }
258
+ const provider = level.provider ?? cfg?.provider;
259
+ if (typeof provider !== 'string') {
260
+ return null;
261
+ }
262
+ return { provider, model: level.model, effort: level.effort };
263
+ }
264
+
265
+ /**
266
+ * @param {unknown} level
267
+ * @returns {level is {model: string, provider?: unknown, effort?: string}}
268
+ */
269
+ function hasModel(level) {
270
+ return Boolean(level) && typeof (/** @type {any} */ (level).model) === 'string' && /** @type {any} */ (level).model.length > 0;
271
+ }
272
+
273
+ /**
274
+ * The effective provider for the SECOND reviewer's L2 slot: `review.second_levels.L2.provider`
275
+ * when set, else `review.second_provider` (plan §1.3: `second_levels` is a per-level override on
276
+ * top of `second_provider`, mirroring how `levels.Lx.provider` overrides the top-level `provider`).
277
+ * @param {Record<string, any>} cfg
278
+ * @returns {string | undefined}
279
+ */
280
+ function effectiveSecondL2Provider(cfg) {
281
+ return cfg?.review?.second_levels?.L2?.provider ?? cfg?.review?.second_provider ?? undefined;
282
+ }
283
+
284
+ // ── 1. level-missing (ERROR) ────────────────────────────────────────────────
285
+
286
+ /**
287
+ * Two distinct causes, two distinct messages (round-2 MINOR): a level with no model at all, vs.
288
+ * a level that HAS a model but whose provider can't be resolved (neither `levels.Lx.provider` nor
289
+ * the top-level `provider` is a string) — the second used to be reported as "missing or has no
290
+ * model", pointing the user at the wrong fix.
291
+ */
292
+ function checkLevelMissing(cfg) {
293
+ const issues = [];
294
+ for (const name of LEVEL_NAMES) {
295
+ if (effectiveLevel(cfg, name)) {
296
+ continue;
297
+ }
298
+ const message = hasModel(cfg?.levels?.[name])
299
+ ? `levels.${name} has no provider (neither levels.${name}.provider nor the top-level provider is set)`
300
+ : `levels.${name} is missing or has no model`;
301
+ issues.push({ rule: 'level-missing', severity: 'error', message });
302
+ }
303
+ return issues;
304
+ }
305
+
306
+ // ── 2. unknown-model-id (WARN) ──────────────────────────────────────────────
307
+
308
+ /**
309
+ * @param {Record<string, any>} cfg
310
+ * @param {{seenInCache?: Record<string, string[]>}} [opts]
311
+ */
312
+ function checkUnknownModelId(cfg, opts = {}) {
313
+ const issues = [];
314
+ for (const name of LEVEL_NAMES) {
315
+ const level = effectiveLevel(cfg, name);
316
+ if (!level) continue;
317
+ const knownExtra = ownStringList(cfg?.known_extra, level.provider);
318
+ const seen = ownStringList(opts.seenInCache, level.provider);
319
+ if (!isKnownId(level.provider, level.model) && !knownExtra.includes(level.model) && !seen.includes(level.model)) {
320
+ issues.push({
321
+ rule: 'unknown-model-id',
322
+ severity: 'warning',
323
+ message: `levels.${name}.model is not a known ${safeProvider(level.provider)} id, not in known_extra, and not seen-in-cache`,
324
+ });
325
+ }
326
+ }
327
+ return issues;
328
+ }
329
+
330
+ // ── 3. duplicate-level-tuple (ERROR) ────────────────────────────────────────
331
+
332
+ function checkDuplicateLevelTuple(cfg) {
333
+ /** @type {Map<string, string>} */
334
+ const seenTuples = new Map();
335
+ const issues = [];
336
+ for (const name of LEVEL_NAMES) {
337
+ const level = effectiveLevel(cfg, name);
338
+ if (!level) continue;
339
+ const tuple = JSON.stringify([level.provider, level.model, level.effort ?? '']);
340
+ const first = seenTuples.get(tuple);
341
+ if (first) {
342
+ issues.push({
343
+ rule: 'duplicate-level-tuple',
344
+ severity: 'error',
345
+ message: `levels.${first} and levels.${name} resolve to the same (provider, model, effort)`,
346
+ });
347
+ } else {
348
+ seenTuples.set(tuple, name);
349
+ }
350
+ }
351
+ return issues;
352
+ }
353
+
354
+ // ── 4. stop-at-not-l3 (ERROR) ───────────────────────────────────────────────
355
+
356
+ function checkStopAtNotL3(cfg) {
357
+ const stopAt = cfg?.escalation?.stop_at;
358
+ if (stopAt !== undefined && stopAt !== 'L3') {
359
+ return [{ rule: 'stop-at-not-l3', severity: 'error', message: 'escalation.stop_at must be "L3"' }];
360
+ }
361
+ return [];
362
+ }
363
+
364
+ // ── 5. multimodel-missing-second-provider (ERROR) ───────────────────────────
365
+
366
+ function checkMultimodelMissingSecondProvider(cfg) {
367
+ if (cfg?.review?.multimodel === true && !cfg?.review?.second_provider) {
368
+ return [
369
+ {
370
+ rule: 'multimodel-missing-second-provider',
371
+ severity: 'error',
372
+ message: 'review.multimodel is true but review.second_provider is empty',
373
+ },
374
+ ];
375
+ }
376
+ return [];
377
+ }
378
+
379
+ // ── 6. multimodel-effective-l2-collision (ERROR) ────────────────────────────
380
+
381
+ function checkMultimodelEffectiveL2Collision(cfg) {
382
+ if (cfg?.review?.multimodel !== true) {
383
+ return [];
384
+ }
385
+ const primary = effectiveLevel(cfg, 'L2');
386
+ const second = effectiveSecondL2Provider(cfg);
387
+ if (primary && second && primary.provider === second) {
388
+ return [
389
+ {
390
+ rule: 'multimodel-effective-l2-collision',
391
+ severity: 'error',
392
+ message: `the effective L2 provider (${safeProvider(primary.provider)}) equals the second reviewer's effective L2 provider (review.second_levels.L2.provider, else review.second_provider) — multimodel needs two different providers`,
393
+ },
394
+ ];
395
+ }
396
+ return [];
397
+ }
398
+
399
+ // ── 7. judge-family-collision (ERROR, consensus mode only) ─────────────────
400
+
401
+ /**
402
+ * Fires when the L3 judge's provider equals EITHER reviewer's provider (not "both" — the old
403
+ * `&&` version could only ever fire when `reviewer1.provider === reviewer2.provider`, which is
404
+ * EXACTLY rule 6's condition, so rule 7 could never fire independently of rule 6). The judge must
405
+ * be independent of BOTH reviewers, so colliding with just one is already a real defect.
406
+ */
407
+ function checkJudgeFamilyCollision(cfg) {
408
+ if (cfg?.review?.multimodel !== true) {
409
+ return [];
410
+ }
411
+ const judge = effectiveLevel(cfg, 'L3');
412
+ if (!judge) {
413
+ return [];
414
+ }
415
+ const reviewer1 = effectiveLevel(cfg, 'L2');
416
+ const reviewer2Provider = effectiveSecondL2Provider(cfg);
417
+ const collidesWithReviewer1 = Boolean(reviewer1) && judge.provider === reviewer1.provider;
418
+ const collidesWithReviewer2 = Boolean(reviewer2Provider) && judge.provider === reviewer2Provider;
419
+ if (collidesWithReviewer1 || collidesWithReviewer2) {
420
+ const which = collidesWithReviewer1 && collidesWithReviewer2 ? 'both reviewers' : collidesWithReviewer1 ? 'reviewer 1 (L2)' : 'reviewer 2 (second_levels.L2)';
421
+ return [
422
+ {
423
+ rule: 'judge-family-collision',
424
+ severity: 'error',
425
+ message: `the L3 judge's provider (${safeProvider(judge.provider)}) equals ${which}'s provider — the judge can't independently check a reviewer it shares a family with`,
426
+ },
427
+ ];
428
+ }
429
+ return [];
430
+ }
431
+
432
+ // ── 8. orchestrator-l3-multimodel-off (WARN) ────────────────────────────────
433
+
434
+ function checkOrchestratorL3MultimodelOff(cfg) {
435
+ if (cfg?.orchestrator === 'L3' && cfg?.review?.multimodel !== true) {
436
+ return [
437
+ {
438
+ rule: 'orchestrator-l3-multimodel-off',
439
+ severity: 'warning',
440
+ message: 'orchestrator is L3 but review.multimodel is off',
441
+ },
442
+ ];
443
+ }
444
+ return [];
445
+ }
446
+
447
+ // ── 9. gates-not-argv-array (ERROR) ─────────────────────────────────────────
448
+
449
+ const GATE_KEYS = /** @type {const} */ (['test', 'lint', 'types', 'format']);
450
+
451
+ function isArgvArray(value) {
452
+ // A non-empty array of strings whose first element (the command itself) is non-empty — `[]`
453
+ // is rejected too: `null` already documents "disabled", so an empty argv would run nothing.
454
+ return Array.isArray(value) && value.length > 0 && value.every((item) => typeof item === 'string') && value[0].length > 0;
455
+ }
456
+
457
+ function checkGatesNotArgvArray(cfg) {
458
+ const issues = [];
459
+ for (const key of GATE_KEYS) {
460
+ const value = cfg?.gates?.[key];
461
+ if (value !== undefined && value !== null && !isArgvArray(value)) {
462
+ issues.push({
463
+ rule: 'gates-not-argv-array',
464
+ severity: 'error',
465
+ message: `gates.${key} must be a non-empty argv array of strings, or null`,
466
+ });
467
+ }
468
+ }
469
+ return issues;
470
+ }
471
+
472
+ // ── 10. secret-looking-value (ERROR) ────────────────────────────────────────
473
+
474
+ /**
475
+ * Checks every string VALUE and every map KEY. A key that itself looks like a secret is reported
476
+ * at its parent path with the key masked — the message must never print the key it is refusing.
477
+ * @param {unknown} node
478
+ * @param {string} path
479
+ * @param {ValidationIssue[]} out
480
+ */
481
+ function walkForSecrets(node, path, out) {
482
+ if (typeof node === 'string') {
483
+ if (looksLikeSecret(node)) {
484
+ out.push({
485
+ rule: 'secret-looking-value',
486
+ severity: 'error',
487
+ message: `${path || '(root)'} looks like a secret value — config must hold references, never plaintext secrets`,
488
+ });
489
+ }
490
+ return;
491
+ }
492
+ if (Array.isArray(node)) {
493
+ node.forEach((item, i) => walkForSecrets(item, `${path}[${i}]`, out));
494
+ return;
495
+ }
496
+ if (node !== null && typeof node === 'object') {
497
+ for (const [key, value] of Object.entries(node)) {
498
+ const childPath = path ? `${path}.${safeSegment(key)}` : safeSegment(key);
499
+ if (looksLikeSecret(key)) {
500
+ out.push({
501
+ rule: 'secret-looking-value',
502
+ severity: 'error',
503
+ message: `${childPath} has a key that looks like a secret — config must hold references, never plaintext secrets`,
504
+ });
505
+ }
506
+ walkForSecrets(value, childPath, out);
507
+ }
508
+ }
509
+ }
510
+
511
+ function checkSecretLookingValue(cfg) {
512
+ const issues = [];
513
+ walkForSecrets(cfg, '', issues);
514
+ return issues;
515
+ }
516
+
517
+ // ── 11. fallback-unknown-or-cli-absent (WARN) ───────────────────────────────
518
+
519
+ /**
520
+ * `levels.<name>.fallback` is documented (and schema-required, when present) to be an array of
521
+ * `{provider, model, effort?}` objects — but `validateConfig` must not crash when it isn't (a
522
+ * string, an object, an array containing `null`, …): that shape violation is ALREADY the schema
523
+ * layer's job to report as a `rule: "schema"` error. This function only ever reads a fallback
524
+ * entry once it has confirmed, itself, that the entry is a plain object with string
525
+ * `provider`/`model` — anything else is skipped, never dereferenced.
526
+ * @param {Record<string, any>} cfg
527
+ * @param {{seenInCache?: Record<string, string[]>, hasCliOnPath?: (provider: string) => boolean}} [opts]
528
+ */
529
+ function checkFallbackUnknownOrCliAbsent(cfg, opts = {}) {
530
+ const issues = [];
531
+ for (const name of LEVEL_NAMES) {
532
+ const rawFallback = cfg?.levels?.[name]?.fallback;
533
+ const fallbackList = Array.isArray(rawFallback) ? rawFallback : [];
534
+ for (const [i, fb] of fallbackList.entries()) {
535
+ if (fb === null || typeof fb !== 'object' || typeof fb.provider !== 'string' || typeof fb.model !== 'string') {
536
+ continue; // malformed entry — the schema layer's own required/type checks report this
537
+ }
538
+ const knownExtra = ownStringList(cfg?.known_extra, fb.provider);
539
+ const seen = ownStringList(opts.seenInCache, fb.provider);
540
+ const modelUnknown =
541
+ !isKnownId(fb.provider, fb.model) && !knownExtra.includes(fb.model) && !seen.includes(fb.model);
542
+ const cliAbsent = typeof opts.hasCliOnPath === 'function' && !opts.hasCliOnPath(fb.provider);
543
+ if (modelUnknown || cliAbsent) {
544
+ issues.push({
545
+ rule: 'fallback-unknown-or-cli-absent',
546
+ severity: 'warning',
547
+ message: `levels.${name}.fallback[${i}] (provider ${safeProvider(fb.provider)})${modelUnknown ? ' has an unknown model id' : ''}${
548
+ modelUnknown && cliAbsent ? ' and' : ''
549
+ }${cliAbsent ? ' has no CLI on PATH' : ''}`,
550
+ });
551
+ }
552
+ }
553
+ }
554
+ return issues;
555
+ }
556
+
557
+ // ── 12. engine-subprocess-no-cli (ERROR) ────────────────────────────────────
558
+
559
+ /**
560
+ * Checks EVERY distinct effective provider `engine: subprocess` could end up spawning — all four
561
+ * levels' own (possibly per-level-overridden) provider, plus the second reviewer's effective L2
562
+ * provider when multimodel is on — not just the top-level `cfg.provider`.
563
+ * @param {Record<string, any>} cfg
564
+ * @param {{hasCliOnPath?: (provider: string) => boolean}} [opts]
565
+ */
566
+ function checkEngineSubprocessNoCli(cfg, opts = {}) {
567
+ if (cfg?.engine !== 'subprocess' || typeof opts.hasCliOnPath !== 'function') {
568
+ return [];
569
+ }
570
+ /** @type {Set<string>} */
571
+ const providers = new Set();
572
+ for (const name of LEVEL_NAMES) {
573
+ const level = effectiveLevel(cfg, name);
574
+ if (level) {
575
+ providers.add(level.provider);
576
+ }
577
+ }
578
+ if (cfg?.review?.multimodel === true) {
579
+ const second = effectiveSecondL2Provider(cfg);
580
+ if (typeof second === 'string') {
581
+ providers.add(second);
582
+ }
583
+ }
584
+ const missing = [...providers].filter((provider) => !opts.hasCliOnPath(provider));
585
+ if (missing.length > 0) {
586
+ const named = [...new Set(missing.map(safeProvider))].sort();
587
+ return [
588
+ {
589
+ rule: 'engine-subprocess-no-cli',
590
+ severity: 'error',
591
+ message: `engine is "subprocess" but no CLI was found on PATH for provider(s): ${named.join(', ')}`,
592
+ },
593
+ ];
594
+ }
595
+ return [];
596
+ }
597
+
598
+ // ── 13. shadow-rate-range (ERROR) ───────────────────────────────────────────
599
+
600
+ function checkShadowRateRange(cfg) {
601
+ const rate = cfg?.calibration?.shadow_rate;
602
+ // `!(rate >= 0 && rate <= 0.5)`, not `rate < 0 || rate > 0.5` — NaN fails BOTH `<`/`>`
603
+ // comparisons, so the old form silently let YAML's `.nan` through.
604
+ if (typeof rate === 'number' && !(rate >= 0 && rate <= 0.5)) {
605
+ return [
606
+ {
607
+ rule: 'shadow-rate-range',
608
+ severity: 'error',
609
+ message: 'calibration.shadow_rate must be a number within [0, 0.5]',
610
+ },
611
+ ];
612
+ }
613
+ return [];
614
+ }
615
+
616
+ // ── 14. caps-coders-exceeds-cap (WARN, v1.3/B1.1) ───────────────────────────
617
+
618
+ /**
619
+ * R10 caps concurrent coders at 2 for THIS build, but a consumer's config is free to set more —
620
+ * the schema itself only enforces `minimum: 1` (plan §1.3: "`caps.coders > 2` ⇒ warn (R10 binds
621
+ * this build; a consumer may run more with the warning on record)"). Guarded on a real finite
622
+ * number so a non-numeric value — already a schema-layer error on its own — never also fires this
623
+ * rule.
624
+ * @param {Record<string, any>} cfg
625
+ */
626
+ function checkCapsCodersExceedsCap(cfg) {
627
+ const coders = cfg?.caps?.coders;
628
+ if (typeof coders === 'number' && Number.isFinite(coders) && coders > 2) {
629
+ return [
630
+ {
631
+ rule: 'caps-coders-exceeds-cap',
632
+ severity: 'warning',
633
+ message: 'caps.coders is above the R10 cap of 2 concurrent coders — allowed, but recorded as a deviation',
634
+ },
635
+ ];
636
+ }
637
+ return [];
638
+ }
639
+
640
+ /**
641
+ * The 14 domain rules paired with the exact `rule` id string each one emits — the single source
642
+ * of truth both `DOMAIN_RULES` (below, what `validateConfig` runs) and the exported
643
+ * `DOMAIN_RULE_IDS` are built from. `'schema'` (the ajv layer's own issues) is deliberately not
644
+ * one of these 14. (B1.2/v1.3, Q16 cut: `proof-tool-absent-for-high-tier` removed, 15 -> 14.)
645
+ * @type {ReadonlyArray<{fn: (cfg: Record<string, any>, opts?: object) => ValidationIssue[], id: string}>}
646
+ */
647
+ const RULE_TABLE = Object.freeze([
648
+ { fn: checkLevelMissing, id: 'level-missing' },
649
+ { fn: checkUnknownModelId, id: 'unknown-model-id' },
650
+ { fn: checkDuplicateLevelTuple, id: 'duplicate-level-tuple' },
651
+ { fn: checkStopAtNotL3, id: 'stop-at-not-l3' },
652
+ { fn: checkMultimodelMissingSecondProvider, id: 'multimodel-missing-second-provider' },
653
+ { fn: checkMultimodelEffectiveL2Collision, id: 'multimodel-effective-l2-collision' },
654
+ { fn: checkJudgeFamilyCollision, id: 'judge-family-collision' },
655
+ { fn: checkOrchestratorL3MultimodelOff, id: 'orchestrator-l3-multimodel-off' },
656
+ { fn: checkGatesNotArgvArray, id: 'gates-not-argv-array' },
657
+ { fn: checkSecretLookingValue, id: 'secret-looking-value' },
658
+ { fn: checkFallbackUnknownOrCliAbsent, id: 'fallback-unknown-or-cli-absent' },
659
+ { fn: checkEngineSubprocessNoCli, id: 'engine-subprocess-no-cli' },
660
+ { fn: checkShadowRateRange, id: 'shadow-rate-range' },
661
+ { fn: checkCapsCodersExceedsCap, id: 'caps-coders-exceeds-cap' },
662
+ ]);
663
+
664
+ /** The 14 domain rules, in the order documented above. */
665
+ const DOMAIN_RULES = Object.freeze(RULE_TABLE.map((entry) => entry.fn));
666
+
667
+ /** The 14 rule ids `validateConfig` can emit, in `DOMAIN_RULES` order (see `RULE_TABLE`). */
668
+ export const DOMAIN_RULE_IDS = Object.freeze(RULE_TABLE.map((entry) => entry.id));
669
+
670
+ /**
671
+ * @param {Record<string, any>} cfg - a parsed (migrated) config object — need not already be schema-valid.
672
+ * @param {{
673
+ * seenInCache?: Record<string, string[]>,
674
+ * hasCliOnPath?: (provider: string) => boolean,
675
+ * }} [opts]
676
+ * @returns {ValidationResult}
677
+ */
678
+ export function validateConfig(cfg, opts = {}) {
679
+ const schemaIssues = runSchemaValidation(cfg);
680
+ const domainIssues = DOMAIN_RULES.flatMap((rule) => rule(cfg, opts));
681
+ /** @type {Set<string>} */
682
+ const secretStrings = new Set();
683
+ collectSecretStrings(cfg, secretStrings);
684
+ const secretsLongestFirst = [...secretStrings].sort((a, b) => b.length - a.length);
685
+ const all = [...schemaIssues, ...domainIssues].map((issue) => ({
686
+ ...issue,
687
+ message: finalizeMessage(issue.message, secretsLongestFirst),
688
+ }));
689
+ const errors = all.filter((issue) => issue.severity === 'error');
690
+ const warnings = all.filter((issue) => issue.severity === 'warning');
691
+ return { valid: errors.length === 0, errors, warnings };
692
+ }
693
+
694
+ export { schema as configSchema };