@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.
- package/CHANGELOG.md +64 -0
- package/LICENSE +21 -0
- package/README.md +127 -0
- package/bin/code-forge.mjs +145 -0
- package/docs/README.md +37 -0
- package/docs/reference/blocks.json +288 -0
- package/docs/reference/config.md +162 -0
- package/examples/first-use/README.md +11 -0
- package/examples/first-use/clamp.acceptance.yml +8 -0
- package/examples/first-use/clamp.brief.md +16 -0
- package/examples/first-use/coder/src/math.mjs +31 -0
- package/examples/first-use/coder/test/clamp.test.mjs +26 -0
- package/examples/node-lib/.env.example +3 -0
- package/examples/node-lib/.gitattributes +3 -0
- package/examples/node-lib/.github/workflows/test.yml +11 -0
- package/examples/node-lib/CHANGELOG.md +13 -0
- package/examples/node-lib/README.md +17 -0
- package/examples/node-lib/gitignore +3 -0
- package/examples/node-lib/package.json +19 -0
- package/examples/node-lib/src/list.mjs +23 -0
- package/examples/node-lib/src/math.mjs +21 -0
- package/examples/node-lib/src/strings.mjs +34 -0
- package/examples/node-lib/test/list.test.mjs +12 -0
- package/examples/node-lib/test/math.test.mjs +13 -0
- package/examples/node-lib/test/package.test.mjs +18 -0
- package/examples/node-lib/test/strings.test.mjs +28 -0
- package/package.json +52 -0
- package/schema/code-forge.schema.json +331 -0
- package/schema/facts.schema.json +28 -0
- package/scripts/first-use.mjs +517 -0
- package/scripts/gen-config-doc.mjs +241 -0
- package/skill/SKILL.md +79 -0
- package/skill/references/adapters/claude-code.md +19 -0
- package/skill/references/adapters/solo.md +34 -0
- package/skill/references/adapters/subprocess.md +15 -0
- package/skill/references/code.md +64 -0
- package/skill/references/continuity.md +31 -0
- package/skill/references/decisions.md +50 -0
- package/skill/references/degraded.md +40 -0
- package/skill/references/harden.md +26 -0
- package/skill/references/ledger.md +23 -0
- package/skill/references/plan.md +43 -0
- package/skill/references/proof.md +34 -0
- package/skill/references/review.md +42 -0
- package/skill/references/rules-core.md +72 -0
- package/skill/references/security.md +29 -0
- package/skill/scripts/forge +40 -0
- package/skill/templates/block-record.md +19 -0
- package/skill/templates/facts-sheet.md +22 -0
- package/skill/templates/plan.md +53 -0
- package/src/cli/author.mjs +87 -0
- package/src/cli/block.mjs +262 -0
- package/src/cli/doctor.mjs +74 -0
- package/src/cli/facts.mjs +124 -0
- package/src/cli/gates.mjs +280 -0
- package/src/cli/help.mjs +48 -0
- package/src/cli/init.mjs +14 -0
- package/src/cli/jev.mjs +175 -0
- package/src/cli/keys.mjs +170 -0
- package/src/cli/ledger.mjs +160 -0
- package/src/cli/list.mjs +68 -0
- package/src/cli/models.mjs +131 -0
- package/src/cli/plan.mjs +70 -0
- package/src/cli/proof.mjs +316 -0
- package/src/cli/remove.mjs +76 -0
- package/src/cli/report.mjs +73 -0
- package/src/cli/resolve.mjs +79 -0
- package/src/cli/review-file.mjs +152 -0
- package/src/cli/run.mjs +159 -0
- package/src/cli/s2.mjs +93 -0
- package/src/cli/spawn.mjs +104 -0
- package/src/cli/upgrade.mjs +103 -0
- package/src/cli/validate.mjs +108 -0
- package/src/cli/version.mjs +18 -0
- package/src/cli/worker.mjs +76 -0
- package/src/config/defaults/anthropic.mjs +18 -0
- package/src/config/defaults/index.mjs +24 -0
- package/src/config/defaults/openai.mjs +16 -0
- package/src/config/defaults/xai.mjs +18 -0
- package/src/config/known-ids.mjs +171 -0
- package/src/config/load.mjs +135 -0
- package/src/config/migrate.mjs +46 -0
- package/src/config/refresh.mjs +276 -0
- package/src/config/schema-compile.mjs +211 -0
- package/src/config/schema-paths.mjs +84 -0
- package/src/config/secret-patterns.mjs +85 -0
- package/src/config/validate.mjs +694 -0
- package/src/decide/author-loop.mjs +90 -0
- package/src/decide/escalation.mjs +262 -0
- package/src/decide/fallback-rules.mjs +172 -0
- package/src/decide/jev-client.mjs +216 -0
- package/src/decide/questions.mjs +161 -0
- package/src/decide/thresholds.mjs +199 -0
- package/src/doctor/index.mjs +105 -0
- package/src/doctor/local.mjs +162 -0
- package/src/doctor/probes.mjs +358 -0
- package/src/doctor/rows.mjs +51 -0
- package/src/doctor/worker-probe.mjs +125 -0
- package/src/engines/alias-map.mjs +78 -0
- package/src/engines/builders/claude.mjs +224 -0
- package/src/engines/builders/codex.mjs +242 -0
- package/src/engines/builders/grok.mjs +132 -0
- package/src/engines/builders/index.mjs +35 -0
- package/src/engines/builders/validate-params.mjs +34 -0
- package/src/engines/codex-home.mjs +230 -0
- package/src/engines/isolation.mjs +84 -0
- package/src/engines/probe.mjs +116 -0
- package/src/engines/provider-cli.mjs +24 -0
- package/src/engines/render-rules.mjs +41 -0
- package/src/engines/sentinel.mjs +133 -0
- package/src/engines/usage-parse.mjs +149 -0
- package/src/gates/acceptance.mjs +95 -0
- package/src/gates/detect.mjs +187 -0
- package/src/gates/invariants.mjs +43 -0
- package/src/gates/run.mjs +72 -0
- package/src/gates/safe-edit.mjs +266 -0
- package/src/gates/scope.mjs +154 -0
- package/src/gates/secret-scan.mjs +79 -0
- package/src/gates/transcript-grep.mjs +27 -0
- package/src/install/agent-env.mjs +76 -0
- package/src/install/detect.mjs +121 -0
- package/src/install/harnesses.mjs +131 -0
- package/src/install/link.mjs +463 -0
- package/src/install/wizard/answers.mjs +389 -0
- package/src/install/wizard/flags.mjs +248 -0
- package/src/install/wizard/project-file.mjs +128 -0
- package/src/install/wizard/run.mjs +366 -0
- package/src/install/wizard/steps.mjs +201 -0
- package/src/keys/backends/constants.mjs +7 -0
- package/src/keys/backends/file.mjs +99 -0
- package/src/keys/backends/keychain.mjs +81 -0
- package/src/keys/backends/security-cli.mjs +105 -0
- package/src/keys/onepassword.mjs +46 -0
- package/src/keys/store.mjs +347 -0
- package/src/ledger/calibration.mjs +109 -0
- package/src/ledger/export.mjs +52 -0
- package/src/ledger/outcome.mjs +397 -0
- package/src/ledger/paths.mjs +30 -0
- package/src/ledger/prices.mjs +49 -0
- package/src/ledger/report.mjs +250 -0
- package/src/ledger/write.mjs +279 -0
- package/src/proof/export.mjs +358 -0
- package/src/proof/lock.mjs +138 -0
- package/src/proof/red-green.mjs +519 -0
- package/src/proof/red-parse.mjs +103 -0
- package/src/proof/tiers.mjs +62 -0
- package/src/review/budget.mjs +241 -0
- package/src/review/context.mjs +156 -0
- package/src/review/engine.mjs +264 -0
- package/src/review/finding.schema.json +47 -0
- package/src/review/fixloop.mjs +467 -0
- package/src/review/gate-check.mjs +249 -0
- package/src/review/lenses/A.md +22 -0
- package/src/review/lenses/B.md +23 -0
- package/src/review/lenses/full.md +23 -0
- package/src/review/lenses/judge.md +21 -0
- package/src/review/lenses/quick.md +21 -0
- package/src/review/lenses/recheck.md +23 -0
- package/src/review/packet.mjs +355 -0
- package/src/review/triage.mjs +149 -0
- package/src/review/validate-review.mjs +70 -0
- package/src/session/author.mjs +228 -0
- package/src/session/facts.mjs +855 -0
- package/src/session/plan-check.mjs +294 -0
- package/src/session/s2.mjs +164 -0
- package/src/session/spawn.mjs +610 -0
- package/src/state/block.mjs +289 -0
- package/src/state/cli-args.mjs +67 -0
- package/src/state/paths.mjs +92 -0
- package/src/state/registry.mjs +189 -0
- package/src/state/run.mjs +266 -0
- package/src/state/signer.mjs +132 -0
- package/src/util/exec.mjs +315 -0
- package/src/util/forbidden.mjs +787 -0
- package/src/util/git.mjs +381 -0
- package/src/util/log.mjs +100 -0
- package/src/util/reaper.mjs +300 -0
- package/src/util/redact.mjs +212 -0
- package/src/util/tmp.mjs +409 -0
- package/src/worker/engine.mjs +338 -0
- package/src/worker/loop.mjs +392 -0
- package/src/worker/queue.mjs +186 -0
- package/src/worker/review-state.mjs +145 -0
- 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
|
+
}
|