@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,347 @@
1
+ /**
2
+ * The key store and the lookup chain (plan §8.1, D12).
3
+ *
4
+ * Chain, in this order, first hit wins:
5
+ * 1. env var — `env:NAME` from the reference, else `CODE_FORGE_KEY_<NAME>`
6
+ * 2. OS keychain — this store (`@napi-rs/keyring` → macOS `security` CLI → 0600 file)
7
+ * 3. 1Password — `op read <op://…>` (retry once on a first-call timeout); a hit is cached
8
+ * into the store as `{"v", "exp"}` for `cacheHours` (default 8)
9
+ * 4. ask — hidden input, only when the caller passes `ask` (setup only)
10
+ * An entry whose `exp` has passed is deleted on that first read and treated as absent, so the
11
+ * chain moves on to 1Password and refreshes it. A backend that fails to read is skipped with a
12
+ * non-secret reason (backend name + error code) in `errors`; the chain never aborts on it.
13
+ *
14
+ * `source` names where the value came from: `env`, the store backend that held it (`keychain`,
15
+ * `security-cli`, `file`), `op`, or `ask`.
16
+ *
17
+ * Every value this module obtains is handed to `registerSecret` before it is returned, so every
18
+ * redacted output path (`util/log`, `util/redact`) masks it from then on. Nothing here prints.
19
+ *
20
+ * Alongside the secrets the store keeps `index.json` (0600, no values): name → source, backend,
21
+ * exp — what `keys list` shows.
22
+ *
23
+ * @typedef {object} Backend
24
+ * @property {string} name
25
+ * @property {boolean} writable
26
+ * @property {() => Promise<boolean>} available
27
+ * @property {(name: string) => Promise<string|null>} get
28
+ * @property {(name: string, value: string) => Promise<void>} set
29
+ * @property {(name: string) => Promise<boolean>} delete
30
+ *
31
+ * @typedef {{name: string, source: string, backend: string, exp: number|null}} IndexRow
32
+ * @typedef {{value: string|null, source: string|null, exp: number|null, errors: string[]}} Resolution
33
+ */
34
+
35
+ import { readFile } from 'node:fs/promises';
36
+ import os from 'node:os';
37
+ import path from 'node:path';
38
+ import { registerSecret } from '../util/redact.mjs';
39
+ import { createFileBackend, writePrivateFile } from './backends/file.mjs';
40
+ import { createKeychainBackend } from './backends/keychain.mjs';
41
+ import { createSecurityCliBackend } from './backends/security-cli.mjs';
42
+ import { isOpRef, opRead as realOpRead } from './onepassword.mjs';
43
+
44
+ export const DEFAULT_CACHE_HOURS = 8;
45
+ const HOUR_MS = 3_600_000;
46
+ const NAME_PATTERN = /^[a-z][a-z0-9_-]{0,63}$/;
47
+
48
+ export const FILE_FALLBACK_WARNING =
49
+ 'keys: no OS keychain available; secrets are stored in 0600 files under ~/.code-forge/store';
50
+
51
+ /**
52
+ * @param {string} name
53
+ * @returns {string}
54
+ */
55
+ export function assertKeyName(name) {
56
+ if (typeof name !== 'string' || !NAME_PATTERN.test(name)) {
57
+ throw new TypeError('key name must match /^[a-z][a-z0-9_-]{0,63}$/');
58
+ }
59
+ return name;
60
+ }
61
+
62
+ /**
63
+ * @param {string} name
64
+ * @param {string} [ref] - `user` | `op://…` | `env:NAME` | `keychain:<name>`
65
+ * @returns {{envName: string, storeName: string, opRef: string|null}}
66
+ */
67
+ export function parseRef(name, ref) {
68
+ assertKeyName(name);
69
+ const envName = ref?.startsWith('env:') ? ref.slice(4) : `CODE_FORGE_KEY_${name.toUpperCase().replace(/-/g, '_')}`;
70
+ const storeName = ref?.startsWith('keychain:') ? assertKeyName(ref.slice(9)) : name;
71
+ if (ref?.startsWith('op://') && !isOpRef(ref)) {
72
+ throw new TypeError(`key "${name}": malformed 1Password reference`);
73
+ }
74
+ return { envName, storeName, opRef: ref?.startsWith('op://') ? ref : null };
75
+ }
76
+
77
+ /**
78
+ * @param {string} value
79
+ * @param {number|null} exp
80
+ * @returns {string}
81
+ */
82
+ export function encodeEntry(value, exp) {
83
+ return JSON.stringify({ v: value, exp });
84
+ }
85
+
86
+ /**
87
+ * A stored string that is not our `{v, exp}` JSON (written by hand, or by another tool) is taken
88
+ * as a raw value with no expiry.
89
+ * @param {string} raw
90
+ * @returns {{value: string, exp: number|null}}
91
+ */
92
+ export function decodeEntry(raw) {
93
+ try {
94
+ const parsed = JSON.parse(raw);
95
+ if (parsed && typeof parsed.v === 'string') {
96
+ return { value: parsed.v, exp: Number.isFinite(parsed.exp) ? parsed.exp : null };
97
+ }
98
+ } catch {
99
+ // not JSON — fall through to raw
100
+ }
101
+ return { value: raw, exp: null };
102
+ }
103
+
104
+ /**
105
+ * @param {unknown} err
106
+ * @returns {string} a reason that can never carry a value: the error's code or class name only.
107
+ */
108
+ function reasonOf(err) {
109
+ const e = /** @type {{code?: unknown, name?: unknown}} */ (err ?? {});
110
+ return typeof e.code === 'string' ? e.code : typeof e.name === 'string' ? e.name : 'Error';
111
+ }
112
+
113
+ /**
114
+ * @param {NodeJS.ProcessEnv} [env]
115
+ * @returns {string}
116
+ */
117
+ export function codeForgeHome(env = process.env) {
118
+ return path.join(env.HOME || os.homedir(), '.code-forge');
119
+ }
120
+
121
+ /**
122
+ * @param {object} opts
123
+ * @param {Backend[]} opts.backends - candidates, most preferred first; unavailable ones are dropped.
124
+ * @param {string} opts.dir - where `index.json` lives.
125
+ */
126
+ export async function createKeyStore({ backends, dir }) {
127
+ /** @type {Backend[]} */
128
+ const live = [];
129
+ for (const backend of backends) {
130
+ if (await backend.available().catch(() => false)) {
131
+ live.push(backend);
132
+ }
133
+ }
134
+ const writer = live.find((b) => b.writable) ?? null;
135
+ // The writer is read first: a fresh value cached there must win over a stale copy that a
136
+ // read-only backend still holds.
137
+ const readOrder = writer ? [writer, ...live.filter((b) => b !== writer)] : live;
138
+ const indexFile = path.join(dir, 'index.json');
139
+
140
+ /** @returns {Promise<Record<string, Omit<IndexRow, 'name'>>>} a missing or corrupt index reads as empty */
141
+ const readIndex = async () => {
142
+ try {
143
+ const parsed = JSON.parse(await readFile(indexFile, 'utf8'));
144
+ return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : {};
145
+ } catch (err) {
146
+ if (err?.code === 'ENOENT' || err instanceof SyntaxError) {
147
+ return {};
148
+ }
149
+ throw err;
150
+ }
151
+ };
152
+ /** @param {(index: Record<string, Omit<IndexRow, 'name'>>) => void} change */
153
+ const updateIndex = async (change) => {
154
+ const index = await readIndex();
155
+ change(index);
156
+ await writePrivateFile(indexFile, JSON.stringify(index, null, 2));
157
+ };
158
+
159
+ return {
160
+ backends: live.map((b) => b.name),
161
+ writer: writer?.name ?? null,
162
+ warning: writer?.name === 'file' ? FILE_FALLBACK_WARNING : null,
163
+
164
+ /**
165
+ * @param {string} name
166
+ * @param {number} now
167
+ * @param {string[]} [errors] - receives one non-secret reason per backend that failed.
168
+ * @returns {Promise<{value: string, exp: number|null, backend: string}|null>}
169
+ */
170
+ async read(name, now, errors = []) {
171
+ assertKeyName(name);
172
+ for (const backend of readOrder) {
173
+ let raw;
174
+ try {
175
+ raw = await backend.get(name);
176
+ } catch (err) {
177
+ errors.push(`${backend.name}: read failed (${reasonOf(err)})`);
178
+ continue;
179
+ }
180
+ if (raw === null) {
181
+ continue;
182
+ }
183
+ const entry = decodeEntry(raw);
184
+ registerSecret(entry.value);
185
+ if (entry.exp !== null && entry.exp <= now) {
186
+ await backend.delete(name).catch((err) => {
187
+ errors.push(`${backend.name}: delete of expired entry failed (${reasonOf(err)})`);
188
+ });
189
+ await updateIndex((index) => {
190
+ delete index[name];
191
+ });
192
+ return null;
193
+ }
194
+ return { ...entry, backend: backend.name };
195
+ }
196
+ return null;
197
+ },
198
+
199
+ /**
200
+ * @param {string} name
201
+ * @param {string} value
202
+ * @param {{source: string, exp: number|null}} meta
203
+ */
204
+ async put(name, value, { source, exp }) {
205
+ assertKeyName(name);
206
+ if (!writer) {
207
+ throw new Error('keys: no writable backend');
208
+ }
209
+ registerSecret(value);
210
+ await writer.set(name, encodeEntry(value, exp));
211
+ await updateIndex((index) => {
212
+ index[name] = { source, backend: writer.name, exp };
213
+ });
214
+ },
215
+
216
+ /** @param {string} name @returns {Promise<number>} how many backends held it */
217
+ async remove(name) {
218
+ assertKeyName(name);
219
+ let removed = 0;
220
+ for (const backend of live) {
221
+ if (await backend.delete(name).catch(() => false)) {
222
+ removed += 1;
223
+ }
224
+ }
225
+ await updateIndex((index) => {
226
+ delete index[name];
227
+ });
228
+ return removed;
229
+ },
230
+
231
+ /** @returns {Promise<IndexRow[]>} metadata only — never a value */
232
+ async list() {
233
+ const index = await readIndex();
234
+ return Object.keys(index)
235
+ .sort()
236
+ .map((name) => ({
237
+ name,
238
+ source: String(index[name].source),
239
+ backend: String(index[name].backend),
240
+ exp: index[name].exp ?? null,
241
+ }));
242
+ },
243
+ };
244
+ }
245
+
246
+ /** @typedef {Awaited<ReturnType<typeof createKeyStore>>} KeyStore */
247
+
248
+ /**
249
+ * The production store: keyring → `security` CLI → 0600 file. `CODE_FORGE_KEY_BACKEND=file`
250
+ * pins the file backend (CI, containers, tests). The keychain factories are injectable so a
251
+ * test never constructs a real keychain backend.
252
+ *
253
+ * @param {NodeJS.ProcessEnv} [env]
254
+ * @param {object} [factories]
255
+ * @param {() => Backend} [factories.keychain]
256
+ * @param {() => Backend} [factories.securityCli]
257
+ * @returns {Promise<KeyStore>}
258
+ */
259
+ export function createDefaultKeyStore(
260
+ env = process.env,
261
+ { keychain = () => createKeychainBackend(), securityCli = () => createSecurityCliBackend() } = {},
262
+ ) {
263
+ const dir = path.join(codeForgeHome(env), 'store');
264
+ const file = createFileBackend({ dir });
265
+ const backends = env.CODE_FORGE_KEY_BACKEND === 'file' ? [file] : [keychain(), securityCli(), file];
266
+ return createKeyStore({ backends, dir });
267
+ }
268
+
269
+ /**
270
+ * Read a key from 1Password and cache it into the store as `{v, exp}` for `cacheHours`. A cache
271
+ * write failure is reported in `errors` but does not lose the value just read.
272
+ *
273
+ * @param {string} storeName
274
+ * @param {string} opRef
275
+ * @param {object} deps
276
+ * @param {KeyStore} deps.store
277
+ * @param {typeof realOpRead} [deps.opRead]
278
+ * @param {number} [deps.now]
279
+ * @param {number} [deps.cacheHours]
280
+ * @returns {Promise<Resolution>}
281
+ */
282
+ export async function readOpAndCache(storeName, opRef, { store, opRead = realOpRead, now = Date.now(), cacheHours = DEFAULT_CACHE_HOURS }) {
283
+ const res = await opRead(opRef);
284
+ if (res.value === null) {
285
+ return { value: null, source: null, exp: null, errors: [res.error ?? 'op read failed'] };
286
+ }
287
+ registerSecret(res.value);
288
+ const exp = now + cacheHours * HOUR_MS;
289
+ /** @type {string[]} */
290
+ const errors = [];
291
+ await store.put(storeName, res.value, { source: 'op', exp }).catch((err) => {
292
+ errors.push(`cache write failed (${reasonOf(err)})`);
293
+ });
294
+ return { value: res.value, source: 'op', exp, errors };
295
+ }
296
+
297
+ /**
298
+ * Resolve one key through the chain. Returns the value with its source; never prints it.
299
+ *
300
+ * @param {string} name
301
+ * @param {object} deps
302
+ * @param {KeyStore} deps.store
303
+ * @param {string} [deps.ref]
304
+ * @param {NodeJS.ProcessEnv} [deps.env]
305
+ * @param {typeof realOpRead} [deps.opRead]
306
+ * @param {(name: string) => Promise<string|null>} [deps.ask] - setup only; omitted everywhere else.
307
+ * @param {number} [deps.now]
308
+ * @param {number} [deps.cacheHours]
309
+ * @returns {Promise<Resolution>}
310
+ */
311
+ export async function resolveKey(name, { store, ref, env = process.env, opRead = realOpRead, ask, now = Date.now(), cacheHours = DEFAULT_CACHE_HOURS }) {
312
+ const { envName, storeName, opRef } = parseRef(name, ref);
313
+ /** @type {string[]} */
314
+ const errors = [];
315
+
316
+ const fromEnv = env[envName];
317
+ if (typeof fromEnv === 'string' && fromEnv.length > 0) {
318
+ registerSecret(fromEnv);
319
+ return { value: fromEnv, source: 'env', exp: null, errors };
320
+ }
321
+
322
+ const cached = await store.read(storeName, now, errors);
323
+ if (cached) {
324
+ return { value: cached.value, source: cached.backend, exp: cached.exp, errors };
325
+ }
326
+
327
+ if (opRef) {
328
+ const fromOp = await readOpAndCache(storeName, opRef, { store, opRead, now, cacheHours });
329
+ errors.push(...fromOp.errors);
330
+ if (fromOp.value !== null) {
331
+ return { ...fromOp, errors };
332
+ }
333
+ }
334
+
335
+ if (ask) {
336
+ const typed = await ask(name);
337
+ if (typeof typed === 'string' && typed.length > 0) {
338
+ registerSecret(typed);
339
+ await store.put(storeName, typed, { source: 'user', exp: null }).catch((err) => {
340
+ errors.push(`cache write failed (${reasonOf(err)})`);
341
+ });
342
+ return { value: typed, source: 'ask', exp: null, errors };
343
+ }
344
+ }
345
+
346
+ return { value: null, source: null, exp: null, errors };
347
+ }
@@ -0,0 +1,109 @@
1
+ /**
2
+ * Calibration (`code-forge ledger calibration`, plan §6.3): per-question reliability buckets ×
3
+ * outcome, plus the shadow-sampling gate that feeds one of the four ground-truth callers.
4
+ *
5
+ * Bucket edges (8): a `noul` confidence is `max(p, 1-p)`, never below 0.5, so the table spans
6
+ * [0.5, 1.0]. The last bucket is CLOSED at 1.0 so Jev's `1.00` lane answers land inside it (plan
7
+ * §3.3: "Jev's 1.00 lane answers are tracked in the [0.99, 1.0] bucket"). Confidence is rounded to
8
+ * 6 decimal places before bucketing (fix round 1) — a value meant to sit exactly on an edge (e.g.
9
+ * `max(p, 1-p)` computing 0.85) can arrive as `0.8499999999999999` from float arithmetic and would
10
+ * otherwise land one bucket low.
11
+ */
12
+
13
+ export const CALIBRATION_BUCKETS = Object.freeze([
14
+ { id: '0.50-0.60', min: 0.5, max: 0.6 },
15
+ { id: '0.60-0.70', min: 0.6, max: 0.7 },
16
+ { id: '0.70-0.80', min: 0.7, max: 0.8 },
17
+ { id: '0.80-0.85', min: 0.8, max: 0.85 },
18
+ { id: '0.85-0.90', min: 0.85, max: 0.9 },
19
+ { id: '0.90-0.95', min: 0.9, max: 0.95 },
20
+ { id: '0.95-0.99', min: 0.95, max: 0.99 },
21
+ { id: '0.99-1.00', min: 0.99, max: 1.0, closed: true },
22
+ ]);
23
+
24
+ export const OUTCOMES = Object.freeze(['correct', 'wrong', 'unknown']);
25
+
26
+ /** Ledger vocabularies map onto the 3-outcome grid explicitly — a `reverted` mark (from
27
+ * `--scan-git`) is ground truth that the original call was WRONG, not an unlabelled unknown. */
28
+ const OUTCOME_MAP = Object.freeze({ correct: 'correct', wrong: 'wrong', reverted: 'wrong', unknown: 'unknown' });
29
+
30
+ const ROUND_TO = 1e6;
31
+ const roundConfidence = (c) => Math.round(c * ROUND_TO) / ROUND_TO;
32
+
33
+ /** @param {number} confidence @returns {{id: string}|null} */
34
+ function bucketFor(confidence) {
35
+ const c = roundConfidence(confidence);
36
+ for (const b of CALIBRATION_BUCKETS) {
37
+ const inRange = b.closed ? c >= b.min && c <= b.max : c >= b.min && c < b.max;
38
+ if (inRange) return b;
39
+ }
40
+ return null;
41
+ }
42
+
43
+ /**
44
+ * @param {any[]} answerRows - rows shaped `{decision_id, question, confidence}`.
45
+ * @param {any[]} outcomeRows - rows shaped `{decision_id, outcome}`. When a `decision_id` has more
46
+ * than one outcome row (e.g. a later `--scan-git` run touching the same decision), the LAST one
47
+ * in array order wins — outcome rows are append-only, so the last is the most recent verdict.
48
+ * @param {{questionId?: string}} [opts] - restrict to one question id; omitted = every question.
49
+ * @returns {{buckets: Record<string, Record<string, number>>, unbacked: number, unmapped: number}}
50
+ * `buckets` always has exactly the 8 `CALIBRATION_BUCKETS` ids, each mapping to exactly the 3
51
+ * `OUTCOMES` — the grid is fully "rendered" even where a cell's count is 0. `unbacked` counts
52
+ * answers whose confidence didn't fall in any bucket (< 0.5); `unmapped` counts answers whose
53
+ * outcome value isn't in `OUTCOME_MAP` — these are NOT folded into the `unknown` cell, so a
54
+ * vocabulary drift is visible instead of silently looking like "no verdict yet".
55
+ */
56
+ export function buildCalibration(answerRows, outcomeRows, opts = {}) {
57
+ const outcomeById = new Map(outcomeRows.map((o) => [o.decision_id, o.outcome]));
58
+ /** @type {Record<string, Record<string, number>>} */
59
+ const buckets = {};
60
+ for (const b of CALIBRATION_BUCKETS) buckets[b.id] = { correct: 0, wrong: 0, unknown: 0 };
61
+
62
+ let unbacked = 0;
63
+ let unmapped = 0;
64
+ for (const row of answerRows) {
65
+ if (opts.questionId && row.question !== opts.questionId) continue;
66
+ const bucket = bucketFor(row.confidence);
67
+ if (!bucket) {
68
+ unbacked += 1;
69
+ continue;
70
+ }
71
+ const rawOutcome = outcomeById.get(row.decision_id);
72
+ if (rawOutcome !== undefined && !Object.hasOwn(OUTCOME_MAP, rawOutcome)) {
73
+ unmapped += 1;
74
+ continue;
75
+ }
76
+ const key = rawOutcome === undefined ? 'unknown' : OUTCOME_MAP[rawOutcome];
77
+ buckets[bucket.id][key] += 1;
78
+ }
79
+ return { buckets, unbacked, unmapped };
80
+ }
81
+
82
+ /**
83
+ * Decide whether ONE S1-decided answer gets shadow-sampled to S2 (plan §6.3 caller 1). Pure and
84
+ * deterministic given `rng`, so "0 of 50 at shadow_rate: 0" is provable, not just observed.
85
+ * @param {number} shadowRate - `calibration.shadow_rate`, schema-validated in [0, 0.5] by B1 — also
86
+ * re-validated here (NaN passes `typeof x === 'number'` and `NaN < 0` is false, so a bare
87
+ * `< 0` guard alone lets a NaN rate silently disable sampling with no error).
88
+ * @param {() => number} [rng] - returns a value in [0, 1); defaults to `Math.random`.
89
+ * @returns {boolean}
90
+ */
91
+ export function shouldShadowSample(shadowRate, rng = Math.random) {
92
+ if (!Number.isFinite(shadowRate) || shadowRate < 0 || shadowRate > 0.5) {
93
+ throw new RangeError(`shouldShadowSample: shadowRate must be a finite number in [0, 0.5], got ${shadowRate}`);
94
+ }
95
+ // Short-circuit at rate 0 without even consulting rng — makes "0 sampled" true by construction,
96
+ // not by a lucky draw, and lets a test prove rng was never called.
97
+ if (shadowRate === 0) return false;
98
+ return rng() < shadowRate;
99
+ }
100
+
101
+ /**
102
+ * @param {any[]} eligibleRows
103
+ * @param {number} shadowRate
104
+ * @param {() => number} [rng]
105
+ * @returns {any[]} the subset sampled.
106
+ */
107
+ export function sampleForShadow(eligibleRows, shadowRate, rng = Math.random) {
108
+ return eligibleRows.filter(() => shouldShadowSample(shadowRate, rng));
109
+ }
@@ -0,0 +1,52 @@
1
+ /**
2
+ * `report --export <dir>` — writes one JSON file per report section (plan §6.2: "13 sections").
3
+ */
4
+
5
+ import { mkdir, writeFile } from 'node:fs/promises';
6
+ import path from 'node:path';
7
+
8
+ /** A section key becomes `<dir>/<key>.json` verbatim — must be a plain, path-safe token. Case is
9
+ * allowed here (not restricted to lowercase) so the SEPARATE case-insensitive collision check
10
+ * below is reachable and meaningful, rather than dead code a lowercase-only pattern would make it. */
11
+ const SAFE_KEY = /^[A-Za-z0-9_-]+$/;
12
+
13
+ /**
14
+ * @param {string} dir
15
+ * @param {Record<string, unknown>} sections
16
+ * @returns {Promise<string[]>} the file paths written, one per section key, in `Object.keys` order.
17
+ * @throws {Error} if a key is unsafe (path traversal / separators), collides with another key
18
+ * case-insensitively (would silently overwrite on a case-insensitive filesystem), or a section's
19
+ * data is `undefined` (`JSON.stringify(undefined)` returns `undefined`, which would otherwise
20
+ * write the literal text `"undefined\n"` — not valid JSON — with no error).
21
+ */
22
+ export async function exportReportSections(dir, sections) {
23
+ const keys = Object.keys(sections);
24
+
25
+ // Validate EVERY key/value up front, before touching the filesystem at all — an export that
26
+ // fails must fail with nothing written, not with some `.json` files present and others missing
27
+ // (fix round 2: the `undefined`-data check used to run inline in the write loop below, so
28
+ // `{a: [1], b: undefined}` wrote `a.json` before throwing on `b`).
29
+ const seenLower = new Set();
30
+ for (const key of keys) {
31
+ if (!SAFE_KEY.test(key)) {
32
+ throw new Error(`exportReportSections: unsafe section key ${JSON.stringify(key)} (must match ${SAFE_KEY})`);
33
+ }
34
+ const lower = key.toLowerCase();
35
+ if (seenLower.has(lower)) {
36
+ throw new Error(`exportReportSections: section key ${JSON.stringify(key)} collides case-insensitively with another key`);
37
+ }
38
+ seenLower.add(lower);
39
+ if (sections[key] === undefined) {
40
+ throw new Error(`exportReportSections: section ${JSON.stringify(key)} has undefined data`);
41
+ }
42
+ }
43
+
44
+ await mkdir(dir, { recursive: true });
45
+ const paths = [];
46
+ for (const key of keys) {
47
+ const file = path.join(dir, `${key}.json`);
48
+ await writeFile(file, `${JSON.stringify(sections[key], null, 2)}\n`, 'utf8');
49
+ paths.push(file);
50
+ }
51
+ return paths;
52
+ }