@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,216 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The Jev (System 1) HTTP transport (plan §3.1, §3.2, O22). This module owns exactly one job:
|
|
3
|
+
* turn `{state, questions}` into one `POST` to Jev and a typed result — nothing here decides
|
|
4
|
+
* WHETHER to call Jev, picks a fallback, or writes a ledger row (that is `fallback-rules.mjs`,
|
|
5
|
+
* the CLI verb, and later blocks).
|
|
6
|
+
*
|
|
7
|
+
* **Verified request/response shape (research/jev/jev_test.py + jev_results.json, this Mac,
|
|
8
|
+
* 2026-09-24; plan header `[A10]`):** `POST https://api.typesafe.ai/v1/systemone`, header
|
|
9
|
+
* `Authorization: Bearer <key>`, body `{state, model, questions}` where each `questions.<id>` is
|
|
10
|
+
* `{type: "noul"|"choice"|"score", instructions, criteria}`. A successful response body is
|
|
11
|
+
* `{answers: {<id>: <typed answer>}, usage?: {input_tokens, output_tokens}}` — `choice` answers
|
|
12
|
+
* come back `{type, choice, probabilities, confidence}`, `score` `{type, score, legend,
|
|
13
|
+
* probabilities, confidence}`, `noul` `{type, noul}` (a PROBABILITY that the statement is true,
|
|
14
|
+
* NOT a confidence — `thresholds.mjs` computes confidence/margin itself, per O8; this module never
|
|
15
|
+
* reads those API-supplied `confidence` fields).
|
|
16
|
+
*
|
|
17
|
+
* **Security (rule 6, O22):** the key is read from `opts.key` (a plaintext the caller already
|
|
18
|
+
* resolved via B2's chain) and used ONLY in the `Authorization` header — never in the request
|
|
19
|
+
* body, never interpolated into an error message or a log line. This module never calls
|
|
20
|
+
* `registerSecret` itself (the resolver already did, per `src/keys/store.mjs`); it just never
|
|
21
|
+
* prints the value. Every error path here carries only a `kind`/`status`/`requestId` — HTTP
|
|
22
|
+
* status codes and header names, not response bodies that might echo request content back.
|
|
23
|
+
*
|
|
24
|
+
* **Retries (§3.5):** 429 (rate limited), 529 (overloaded), a network failure (fetch rejects) and
|
|
25
|
+
* a per-attempt TIMEOUT (fix round 1 — see below) back off `0.5 s → 2 s → 8 s` between attempts
|
|
26
|
+
* (3 retries after the first try = 4 attempts total), then give up and report the failure — the
|
|
27
|
+
* CALLER decides what "give up" means (fallback rules, one banner). 401 (bad/expired key) and 422
|
|
28
|
+
* (malformed request) never retry: a retry cannot fix either.
|
|
29
|
+
*
|
|
30
|
+
* **Per-attempt timeout (fix round 1, review finding "no per-attempt timeout"):** every attempt
|
|
31
|
+
* carries `signal: AbortSignal.timeout(timeoutMs)` (default `DEFAULT_JEV_TIMEOUT_MS`, 30 s — a
|
|
32
|
+
* generous headroom over Jev's documented ~0.5 s response time, not a measured server-side bound;
|
|
33
|
+
* `[A10]` names no server timeout to match). Without this, the only limit was undici's own
|
|
34
|
+
* default `headersTimeout`/`bodyTimeout` (~300 s), so 4 attempts could block for ~20 minutes
|
|
35
|
+
* before §3.5's fallback ever got control — far too long for an interactive `jev ask`. An abort
|
|
36
|
+
* is reported as `kind: 'timeout'` and retries on the SAME backoff as a network failure.
|
|
37
|
+
*/
|
|
38
|
+
|
|
39
|
+
/** The one Jev endpoint (verified 2026-09-24). */
|
|
40
|
+
export const JEV_URL = 'https://api.typesafe.ai/v1/systemone';
|
|
41
|
+
|
|
42
|
+
/** The model id Jev's own API expects — not one of `levels.Lx`'s model ids (verified). */
|
|
43
|
+
export const JEV_MODEL = 'jev-latest';
|
|
44
|
+
|
|
45
|
+
/** Backoff delays between retryable attempts, in milliseconds (§3.5: "0.5 s → 2 s → 8 s"). */
|
|
46
|
+
export const RETRY_DELAYS_MS = Object.freeze([500, 2000, 8000]);
|
|
47
|
+
|
|
48
|
+
/** Default per-attempt timeout (fix round 1) — see the module header for why 30 s. */
|
|
49
|
+
export const DEFAULT_JEV_TIMEOUT_MS = 30000;
|
|
50
|
+
|
|
51
|
+
/** HTTP statuses that never retry — a wait cannot fix a bad key or a malformed request. */
|
|
52
|
+
const TERMINAL_STATUS_KINDS = Object.freeze({ 401: 'unauthorized', 422: 'invalid_request' });
|
|
53
|
+
|
|
54
|
+
/** HTTP statuses that DO retry, per the backoff schedule above. */
|
|
55
|
+
const RETRYABLE_STATUS_KINDS = Object.freeze({ 429: 'rate_limited', 529: 'unavailable' });
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* @param {number} ms
|
|
59
|
+
* @returns {Promise<void>}
|
|
60
|
+
*/
|
|
61
|
+
function defaultSleep(ms) {
|
|
62
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* @param {Response} response
|
|
67
|
+
* @returns {Promise<string|null>} a request id from the JSON body's `request_id`/`requestId`, else
|
|
68
|
+
* from an `x-request-id` header, else `null` — never anything else from the body (rule: no
|
|
69
|
+
* parser excerpts in a message).
|
|
70
|
+
*/
|
|
71
|
+
async function requestIdOf(response) {
|
|
72
|
+
const header = response.headers?.get?.('x-request-id');
|
|
73
|
+
if (typeof header === 'string' && header.length > 0) {
|
|
74
|
+
return header;
|
|
75
|
+
}
|
|
76
|
+
try {
|
|
77
|
+
const body = await response.clone().json();
|
|
78
|
+
const id = body?.request_id ?? body?.requestId;
|
|
79
|
+
return typeof id === 'string' && id.length > 0 ? id : null;
|
|
80
|
+
} catch {
|
|
81
|
+
return null;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* @typedef {{type: 'choice', choice: string, probabilities: Record<string, number>}} ChoiceAnswer
|
|
87
|
+
* @typedef {{type: 'score', score: number, legend: Record<string, string>, probabilities: Record<string, number>}} ScoreAnswer
|
|
88
|
+
* @typedef {{type: 'noul', noul: number}} NoulAnswer
|
|
89
|
+
* @typedef {ChoiceAnswer|ScoreAnswer|NoulAnswer} JevAnswer
|
|
90
|
+
*
|
|
91
|
+
* @typedef {object} JevQuestionSpec
|
|
92
|
+
* @property {'choice'|'score'|'noul'} type
|
|
93
|
+
* @property {string} instructions
|
|
94
|
+
* @property {Record<string, string>} criteria
|
|
95
|
+
*
|
|
96
|
+
* @typedef {object} JevSuccess
|
|
97
|
+
* @property {true} ok
|
|
98
|
+
* @property {Record<string, JevAnswer>} answers
|
|
99
|
+
* @property {{input_tokens?: number, output_tokens?: number}|undefined} usage
|
|
100
|
+
* @property {number} attempts
|
|
101
|
+
* @property {number} ms
|
|
102
|
+
*
|
|
103
|
+
* @typedef {object} JevFailure
|
|
104
|
+
* @property {false} ok
|
|
105
|
+
* @property {'unauthorized'|'invalid_request'|'rate_limited'|'unavailable'|'network'|'timeout'|'unexpected_status'|'invalid_response'} kind
|
|
106
|
+
* @property {number|null} status
|
|
107
|
+
* @property {string|null} requestId
|
|
108
|
+
* @property {number} attempts
|
|
109
|
+
*/
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* One typed call to Jev, with the documented retry/backoff for 429/529/network. Never throws for
|
|
113
|
+
* a normal failure (bad key, rate limit, …) — always resolves to `{ok, ...}`; only a caller
|
|
114
|
+
* programming error (bad `argv` shape) throws synchronously, before any network call.
|
|
115
|
+
*
|
|
116
|
+
* @param {object} opts
|
|
117
|
+
* @param {Record<string, any>} opts.state
|
|
118
|
+
* @param {Record<string, JevQuestionSpec>} opts.questions
|
|
119
|
+
* @param {string} opts.key - a plaintext already resolved by the caller (B2's chain). Used ONLY
|
|
120
|
+
* in the `Authorization` header.
|
|
121
|
+
* @param {typeof fetch} [opts.fetch] - defaults to Node's global `fetch`; tests always inject one.
|
|
122
|
+
* @param {string} [opts.url]
|
|
123
|
+
* @param {string} [opts.model]
|
|
124
|
+
* @param {(ms: number) => Promise<void>} [opts.sleep] - injectable so a test never really waits.
|
|
125
|
+
* @param {() => number} [opts.now]
|
|
126
|
+
* @param {number} [opts.timeoutMs] - per-attempt timeout; default {@link DEFAULT_JEV_TIMEOUT_MS}.
|
|
127
|
+
* @returns {Promise<JevSuccess|JevFailure>}
|
|
128
|
+
*/
|
|
129
|
+
export async function askJev({
|
|
130
|
+
state,
|
|
131
|
+
questions,
|
|
132
|
+
key,
|
|
133
|
+
fetch: fetchImpl = globalThis.fetch,
|
|
134
|
+
url = JEV_URL,
|
|
135
|
+
model = JEV_MODEL,
|
|
136
|
+
sleep = defaultSleep,
|
|
137
|
+
now = Date.now,
|
|
138
|
+
timeoutMs = DEFAULT_JEV_TIMEOUT_MS,
|
|
139
|
+
}) {
|
|
140
|
+
if (state === null || typeof state !== 'object' || Array.isArray(state)) {
|
|
141
|
+
throw new TypeError('askJev: state must be a plain object');
|
|
142
|
+
}
|
|
143
|
+
if (questions === null || typeof questions !== 'object' || Object.keys(questions).length === 0) {
|
|
144
|
+
throw new TypeError('askJev: questions must be a non-empty object');
|
|
145
|
+
}
|
|
146
|
+
if (typeof key !== 'string' || key.length === 0) {
|
|
147
|
+
throw new TypeError('askJev: key must be a non-empty string');
|
|
148
|
+
}
|
|
149
|
+
if (typeof fetchImpl !== 'function') {
|
|
150
|
+
throw new TypeError('askJev: no fetch implementation available (Node < 18, or none injected)');
|
|
151
|
+
}
|
|
152
|
+
if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) {
|
|
153
|
+
throw new TypeError('askJev: timeoutMs must be a finite number > 0');
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
const body = JSON.stringify({ state, model, questions });
|
|
157
|
+
const started = now();
|
|
158
|
+
const maxAttempts = RETRY_DELAYS_MS.length + 1;
|
|
159
|
+
|
|
160
|
+
for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
|
|
161
|
+
/** @type {Response} */
|
|
162
|
+
let response;
|
|
163
|
+
try {
|
|
164
|
+
response = await fetchImpl(url, {
|
|
165
|
+
method: 'POST',
|
|
166
|
+
headers: { Authorization: `Bearer ${key}`, 'Content-Type': 'application/json' },
|
|
167
|
+
body,
|
|
168
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
169
|
+
});
|
|
170
|
+
} catch (err) {
|
|
171
|
+
// Network failure (DNS, refused connection, …) or OUR OWN per-attempt timeout firing — the
|
|
172
|
+
// exact error text may carry ambient details we never want to log; only a `kind` survives
|
|
173
|
+
// past this catch. `AbortSignal.timeout()` aborts with a `TimeoutError` DOMException; any
|
|
174
|
+
// other rejection (connection refused, DNS failure, …) is the generic "network" kind.
|
|
175
|
+
const kind = err?.name === 'TimeoutError' ? 'timeout' : 'network';
|
|
176
|
+
if (attempt < maxAttempts) {
|
|
177
|
+
await sleep(RETRY_DELAYS_MS[attempt - 1]);
|
|
178
|
+
continue;
|
|
179
|
+
}
|
|
180
|
+
return { ok: false, kind, status: null, requestId: null, attempts: attempt };
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
if (response.ok) {
|
|
184
|
+
let parsed;
|
|
185
|
+
try {
|
|
186
|
+
parsed = await response.json();
|
|
187
|
+
} catch {
|
|
188
|
+
return { ok: false, kind: 'invalid_response', status: response.status, requestId: null, attempts: attempt };
|
|
189
|
+
}
|
|
190
|
+
if (parsed === null || typeof parsed !== 'object' || parsed.answers === null || typeof parsed.answers !== 'object') {
|
|
191
|
+
return { ok: false, kind: 'invalid_response', status: response.status, requestId: null, attempts: attempt };
|
|
192
|
+
}
|
|
193
|
+
return { ok: true, answers: parsed.answers, usage: parsed.usage, attempts: attempt, ms: now() - started };
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
const status = response.status;
|
|
197
|
+
if (Object.hasOwn(TERMINAL_STATUS_KINDS, status)) {
|
|
198
|
+
const requestId = await requestIdOf(response);
|
|
199
|
+
return { ok: false, kind: TERMINAL_STATUS_KINDS[status], status, requestId, attempts: attempt };
|
|
200
|
+
}
|
|
201
|
+
if (Object.hasOwn(RETRYABLE_STATUS_KINDS, status)) {
|
|
202
|
+
if (attempt < maxAttempts) {
|
|
203
|
+
await sleep(RETRY_DELAYS_MS[attempt - 1]);
|
|
204
|
+
continue;
|
|
205
|
+
}
|
|
206
|
+
return { ok: false, kind: RETRYABLE_STATUS_KINDS[status], status, requestId: null, attempts: attempt };
|
|
207
|
+
}
|
|
208
|
+
// An undocumented status: fail fast rather than guess at retry semantics for it.
|
|
209
|
+
return { ok: false, kind: 'unexpected_status', status, requestId: null, attempts: attempt };
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// Unreachable: the loop always returns by `maxAttempts`. Kept only so a mutant that breaks the
|
|
213
|
+
// loop bound cannot silently fall through to `undefined`.
|
|
214
|
+
/* c8 ignore next */
|
|
215
|
+
return { ok: false, kind: 'network', status: null, requestId: null, attempts: maxAttempts };
|
|
216
|
+
}
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The S1 question registry (plan §3.2) — the seven typed questions Jev (or a fallback source)
|
|
3
|
+
* answers, verbatim from the plan's table: `lane`, `risk`, `security_sensitive`, `next`, `scope`,
|
|
4
|
+
* `defect`, `resolved`. Each entry's `type`/`instructions`/`criteria` is exactly what
|
|
5
|
+
* `jev-client.mjs`'s `questions.<id>` argument needs; `fires` and `state` are documentation only
|
|
6
|
+
* (they describe WHEN a caller asks and WHAT it puts in `state` — no code here builds `state`,
|
|
7
|
+
* since that is facts the caller already has, e.g. the block record or a diff stat).
|
|
8
|
+
*
|
|
9
|
+
* `buildQuestionPayload` is the one function callers actually need: it renders one or more
|
|
10
|
+
* registry entries into the `{<id>: {type, instructions, criteria}}` shape `askJev` sends,
|
|
11
|
+
* honouring two config hooks from `system1` (§1.3, §8.3):
|
|
12
|
+
* - `system1.criteria_extra.<id>` — merges (never replaces) extra criteria labels into a
|
|
13
|
+
* question, and may override `instructions` with a project-specific phrasing.
|
|
14
|
+
* - `system1.disable` — a question id in this list is never sent to Jev at all;
|
|
15
|
+
* `buildQuestionPayload` throws rather than silently rendering it, so a caller can't forget to
|
|
16
|
+
* check `isQuestionDisabled` first and accidentally leak a diff hunk to Jev anyway.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* @typedef {object} QuestionSpec
|
|
21
|
+
* @property {'choice'|'score'|'noul'} type
|
|
22
|
+
* @property {string} fires - documentation: when this question is asked.
|
|
23
|
+
* @property {string} state - documentation: what `state` looks like for this question.
|
|
24
|
+
* @property {string} instructions
|
|
25
|
+
* @property {Record<string, string>} criteria
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
/** @type {Readonly<Record<string, QuestionSpec>>} */
|
|
29
|
+
export const QUESTIONS = Object.freeze({
|
|
30
|
+
lane: Object.freeze({
|
|
31
|
+
type: 'choice',
|
|
32
|
+
fires: 'plan: per block; code: on re-decomposition',
|
|
33
|
+
state: '{block:{task, owned_files, acceptance}, facts_excerpt}',
|
|
34
|
+
instructions: 'Which is the lowest model level that can reliably complete `block.task` within `block.owned_files`?',
|
|
35
|
+
criteria: Object.freeze({
|
|
36
|
+
L0: 'trivial',
|
|
37
|
+
L1: 'plain feature, following an existing pattern',
|
|
38
|
+
L2: 'hard (money, dates, tenancy, migrations, concurrency, unknown failure)',
|
|
39
|
+
split: 'the block is too large or mixes two concerns — re-decompose before dispatch',
|
|
40
|
+
}),
|
|
41
|
+
}),
|
|
42
|
+
risk: Object.freeze({
|
|
43
|
+
type: 'score',
|
|
44
|
+
fires: 'plan: per block; code: per finished file',
|
|
45
|
+
state: '{block or file, diff_stat, paths}',
|
|
46
|
+
instructions: 'How much damage could a subtle mistake here cause in production?',
|
|
47
|
+
criteria: Object.freeze({
|
|
48
|
+
0: 'cosmetic',
|
|
49
|
+
1: 'wrong behaviour, easy to notice and revert',
|
|
50
|
+
2: 'silent wrong data for some users',
|
|
51
|
+
3: 'silent wrong money or cross-tenant leak',
|
|
52
|
+
}),
|
|
53
|
+
}),
|
|
54
|
+
security_sensitive: Object.freeze({
|
|
55
|
+
type: 'noul',
|
|
56
|
+
fires: 'plan: per block; code: per file when path floors did not already force high',
|
|
57
|
+
state: '{paths, diff_stat, symbols_touched}',
|
|
58
|
+
instructions:
|
|
59
|
+
'Does this change touch authentication, authorization, tenancy boundaries, secrets, money movement or webhook verification?',
|
|
60
|
+
criteria: Object.freeze({ true: 'yes', false: 'no' }),
|
|
61
|
+
}),
|
|
62
|
+
next: Object.freeze({
|
|
63
|
+
type: 'choice',
|
|
64
|
+
fires: "code: after each attempt's tool gate",
|
|
65
|
+
state: '{evidence:{tests, lint, types, format, attempt, previous_attempts, new_tests_added, no_new_tests_reason, forecast_vs_actual}}',
|
|
66
|
+
instructions: 'Given `evidence` from deterministic checks after a coding attempt, what should the orchestrator do next?',
|
|
67
|
+
criteria: Object.freeze({
|
|
68
|
+
complete: 'all green, tests added or no_new_tests_reason given',
|
|
69
|
+
retry: 'contained failure the same coder can fix in one more attempt',
|
|
70
|
+
escalate: 'repeated or spreading failures, or risky area',
|
|
71
|
+
stop: 'the evidence contradicts the brief (wrong base, missing dependency, acceptance impossible)',
|
|
72
|
+
}),
|
|
73
|
+
}),
|
|
74
|
+
scope: Object.freeze({
|
|
75
|
+
type: 'noul',
|
|
76
|
+
fires: 'code: block gate, once, on the hunks outside the acceptance-named tests',
|
|
77
|
+
state: '{acceptance, owned_files, hunks_outside_named_tests}',
|
|
78
|
+
instructions: 'Do these hunks contain a change unrelated to `acceptance`?',
|
|
79
|
+
criteria: Object.freeze({ true: 'yes', false: 'no' }),
|
|
80
|
+
}),
|
|
81
|
+
defect: Object.freeze({
|
|
82
|
+
type: 'noul',
|
|
83
|
+
fires: 'review: per finding that has not passed a judge',
|
|
84
|
+
state: '{file_diff_hunk, finding}',
|
|
85
|
+
instructions: 'Is `finding` a real defect in `file_diff_hunk` that must be fixed before merge (not a style preference)?',
|
|
86
|
+
criteria: Object.freeze({ true: 'real correctness, safety or rule-violation defect', false: 'style, naming or preference only' }),
|
|
87
|
+
}),
|
|
88
|
+
resolved: Object.freeze({
|
|
89
|
+
type: 'noul',
|
|
90
|
+
fires: 'fix loop: per open finding after a fix',
|
|
91
|
+
state: '{finding, fix_hunk}',
|
|
92
|
+
instructions: 'Does `fix_hunk` resolve `finding` without introducing a new problem in the shown lines?',
|
|
93
|
+
criteria: Object.freeze({ true: 'yes', false: 'no' }),
|
|
94
|
+
}),
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
/** The 7 registered question ids, in table order. */
|
|
98
|
+
export const QUESTION_IDS = Object.freeze(Object.keys(QUESTIONS));
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* @param {string} id
|
|
102
|
+
* @returns {boolean}
|
|
103
|
+
*/
|
|
104
|
+
export function isKnownQuestion(id) {
|
|
105
|
+
return typeof id === 'string' && Object.hasOwn(QUESTIONS, id);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* @param {string} id
|
|
110
|
+
* @param {Record<string, any>} [cfg]
|
|
111
|
+
* @returns {boolean} `true` when `system1.disable` lists `id` — this question must never be sent
|
|
112
|
+
* to Jev (§8.3: "for projects that will not send diff hunks to Jev").
|
|
113
|
+
*/
|
|
114
|
+
export function isQuestionDisabled(id, cfg = {}) {
|
|
115
|
+
const disabled = cfg?.system1?.disable;
|
|
116
|
+
return Array.isArray(disabled) && disabled.includes(id);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Merge a question's shipped `criteria` with `system1.criteria_extra.<id>.criteria` (extra keys
|
|
121
|
+
* win on a collision — a project override is deliberate) and pick `instructions` (the extra one
|
|
122
|
+
* wins when given).
|
|
123
|
+
* @param {string} id
|
|
124
|
+
* @param {Record<string, any>} [cfg]
|
|
125
|
+
* @returns {{type: 'choice'|'score'|'noul', instructions: string, criteria: Record<string, string>}}
|
|
126
|
+
* @throws {TypeError} for an unknown id, or one `system1.disable` lists.
|
|
127
|
+
*/
|
|
128
|
+
export function renderQuestion(id, cfg = {}) {
|
|
129
|
+
if (!isKnownQuestion(id)) {
|
|
130
|
+
throw new TypeError(`renderQuestion: unknown question id ${JSON.stringify(id)}`);
|
|
131
|
+
}
|
|
132
|
+
if (isQuestionDisabled(id, cfg)) {
|
|
133
|
+
throw new TypeError(`renderQuestion: question ${JSON.stringify(id)} is disabled by system1.disable`);
|
|
134
|
+
}
|
|
135
|
+
const spec = QUESTIONS[id];
|
|
136
|
+
const extra = cfg?.system1?.criteria_extra?.[id] ?? {};
|
|
137
|
+
return {
|
|
138
|
+
type: spec.type,
|
|
139
|
+
instructions: typeof extra.instructions === 'string' && extra.instructions.length > 0 ? extra.instructions : spec.instructions,
|
|
140
|
+
criteria: { ...spec.criteria, ...(extra.criteria ?? {}) },
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Render one or more question ids into the `{<id>: {type, instructions, criteria}}` shape
|
|
146
|
+
* `askJev`'s `questions` argument takes.
|
|
147
|
+
* @param {string[]} ids
|
|
148
|
+
* @param {Record<string, any>} [cfg]
|
|
149
|
+
* @returns {Record<string, {type: 'choice'|'score'|'noul', instructions: string, criteria: Record<string, string>}>}
|
|
150
|
+
*/
|
|
151
|
+
export function buildQuestionPayload(ids, cfg = {}) {
|
|
152
|
+
if (!Array.isArray(ids) || ids.length === 0) {
|
|
153
|
+
throw new TypeError('buildQuestionPayload: ids must be a non-empty array');
|
|
154
|
+
}
|
|
155
|
+
/** @type {Record<string, {type: 'choice'|'score'|'noul', instructions: string, criteria: Record<string, string>}>} */
|
|
156
|
+
const out = {};
|
|
157
|
+
for (const id of ids) {
|
|
158
|
+
out[id] = renderQuestion(id, cfg);
|
|
159
|
+
}
|
|
160
|
+
return out;
|
|
161
|
+
}
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The S1 → S2 hand-off maths (plan §3.3, O8) — the ONE place that turns a raw Jev answer into
|
|
3
|
+
* `{confidence, margin}` and decides whether S1 acts, S2 checks, or S2 decides alone.
|
|
4
|
+
*
|
|
5
|
+
* **O8, verbatim:** `choice`/`score` — `confidence = p(top1)`, `margin = p(top1) − p(top2)`
|
|
6
|
+
* (`p(top2) = 0` when there is only one non-zero probability). `noul` — `answer = p ≥ 0.5`,
|
|
7
|
+
* `confidence = max(p, 1 − p)`, `margin = |2p − 1|`. This module NEVER reads an API-supplied
|
|
8
|
+
* `confidence`/`score` field for this purpose — it recomputes from `probabilities`/`noul` every
|
|
9
|
+
* time, so a provider that ever changes how it rounds its own `confidence` field cannot silently
|
|
10
|
+
* drift our thresholds. (A mutant that reads `p` as the confidence for a `noul` answer must turn
|
|
11
|
+
* `decide()` red on the documented `finding:nit` fixture — see `test/decide/probes.test.mjs`.)
|
|
12
|
+
*
|
|
13
|
+
* **Bands (§3.3):** `confidence ≥ act (0.90)` AND `margin ≥ close_margin (0.15)` ⇒ `'act'` (S1
|
|
14
|
+
* decides) · `check (0.60) ≤ confidence < act`, OR `confidence ≥ act` but `margin < close_margin`
|
|
15
|
+
* ⇒ `'check'` (S2 checks S1's answer) · `confidence < check` ⇒ `'decide'` (S2 decides from the
|
|
16
|
+
* state alone). Overridable per question via `thresholds.<id>`, falling back to
|
|
17
|
+
* `thresholds.default`, falling back to the defaults below — `validate.mjs`/the schema never
|
|
18
|
+
* apply these as JSON-Schema `default`s (no `ajv useDefaults` anywhere in this package), so this
|
|
19
|
+
* module is the one place they are real.
|
|
20
|
+
*
|
|
21
|
+
* **Findings keep their own bands** (`thresholds.findings`, §3.3 last sentence): a `defect`
|
|
22
|
+
* answer's raw `noul` PROBABILITY (not confidence) is banded `fix_now` (≥ 0.90) / `judge`
|
|
23
|
+
* (0.40–0.90) / `nit` (< 0.40); a `resolved` answer is `closed` (≥ 0.90) or `recheck` (else).
|
|
24
|
+
* These are a SEPARATE decision from `decide()`'s general act/check/decide bands — a review
|
|
25
|
+
* engine (B12) uses `bandFinding`/`bandResolved` to triage a finding's severity, not to decide
|
|
26
|
+
* whether S1 or S2 owns the call.
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
/** §3.3's documented defaults — used whenever `thresholds.<id>`/`thresholds.default` omit a field. */
|
|
30
|
+
export const DEFAULT_BAND = Object.freeze({ act: 0.9, check: 0.6, close_margin: 0.15 });
|
|
31
|
+
|
|
32
|
+
/** §1.3's documented `thresholds.findings` defaults. */
|
|
33
|
+
export const DEFAULT_FINDINGS = Object.freeze({ fix: 0.9, nit: 0.4, resolved: 0.9 });
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* @typedef {import('./jev-client.mjs').JevAnswer} JevAnswer
|
|
37
|
+
* @typedef {{act: number, check: number, close_margin: number}} ThresholdBand
|
|
38
|
+
*/
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* @param {string} questionId
|
|
42
|
+
* @param {Record<string, any>} [cfg]
|
|
43
|
+
* @returns {ThresholdBand}
|
|
44
|
+
*/
|
|
45
|
+
export function bandsFor(questionId, cfg = {}) {
|
|
46
|
+
const perQuestion = cfg?.thresholds?.[questionId] ?? {};
|
|
47
|
+
const dflt = cfg?.thresholds?.default ?? {};
|
|
48
|
+
return {
|
|
49
|
+
act: perQuestion.act ?? dflt.act ?? DEFAULT_BAND.act,
|
|
50
|
+
check: perQuestion.check ?? dflt.check ?? DEFAULT_BAND.check,
|
|
51
|
+
close_margin: perQuestion.close_margin ?? dflt.close_margin ?? DEFAULT_BAND.close_margin,
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* @param {Record<string, any>} [cfg]
|
|
57
|
+
* @returns {{fix: number, nit: number, resolved: number}}
|
|
58
|
+
*/
|
|
59
|
+
export function findingsThresholds(cfg = {}) {
|
|
60
|
+
const raw = cfg?.thresholds?.findings ?? {};
|
|
61
|
+
return {
|
|
62
|
+
fix: raw.fix ?? DEFAULT_FINDINGS.fix,
|
|
63
|
+
nit: raw.nit ?? DEFAULT_FINDINGS.nit,
|
|
64
|
+
resolved: raw.resolved ?? DEFAULT_FINDINGS.resolved,
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Sort a `probabilities` map's values descending. Tolerant of a single-key map (`p(top2) = 0`,
|
|
70
|
+
* O8's own worked example — a lane answer with only `L0: 1.0` set).
|
|
71
|
+
* @param {Record<string, number>} probabilities
|
|
72
|
+
* @returns {number[]}
|
|
73
|
+
*/
|
|
74
|
+
function sortedProbabilities(probabilities) {
|
|
75
|
+
if (probabilities === null || typeof probabilities !== 'object') {
|
|
76
|
+
throw new TypeError('sortedProbabilities: probabilities must be an object');
|
|
77
|
+
}
|
|
78
|
+
const values = Object.values(probabilities);
|
|
79
|
+
if (values.length === 0 || values.some((v) => typeof v !== 'number' || !Number.isFinite(v))) {
|
|
80
|
+
throw new TypeError('sortedProbabilities: probabilities must hold only finite numbers');
|
|
81
|
+
}
|
|
82
|
+
return [...values].sort((a, b) => b - a);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* @param {Record<string, number>} probabilities
|
|
87
|
+
* @returns {string} the key whose value is the largest (first one, on a tie).
|
|
88
|
+
*/
|
|
89
|
+
function argmax(probabilities) {
|
|
90
|
+
let bestKey = null;
|
|
91
|
+
let bestValue = -Infinity;
|
|
92
|
+
for (const [key, value] of Object.entries(probabilities)) {
|
|
93
|
+
if (value > bestValue) {
|
|
94
|
+
bestValue = value;
|
|
95
|
+
bestKey = key;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
return bestKey;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* O8's confidence/margin maths, plus the decided VALUE (independent of the raw `choice`/`score`
|
|
103
|
+
* field the API also returned, for `choice` — the argmax of `probabilities` is authoritative here
|
|
104
|
+
* so confidence/margin and the reported value can never disagree with each other).
|
|
105
|
+
*
|
|
106
|
+
* `answer` is typed `any`, not the stricter {@link JevAnswer} union, deliberately: this is a
|
|
107
|
+
* boundary function for an external API's parsed JSON — TypeScript cannot verify a network
|
|
108
|
+
* response's shape at compile time regardless of the JSDoc typedef, and this function's entire
|
|
109
|
+
* job IS the runtime validation `JevAnswer` only documents. Every branch below still checks its
|
|
110
|
+
* own shape and throws a `TypeError` on anything that doesn't match.
|
|
111
|
+
* @param {any} answer
|
|
112
|
+
* @returns {{confidence: number, margin: number, value: string|number|boolean}}
|
|
113
|
+
*/
|
|
114
|
+
export function computeConfidenceMargin(answer) {
|
|
115
|
+
if (answer === null || typeof answer !== 'object') {
|
|
116
|
+
throw new TypeError('computeConfidenceMargin: answer must be an object');
|
|
117
|
+
}
|
|
118
|
+
if (answer.type === 'choice') {
|
|
119
|
+
const [top1, top2 = 0] = sortedProbabilities(answer.probabilities);
|
|
120
|
+
return { confidence: top1, margin: top1 - top2, value: argmax(answer.probabilities) };
|
|
121
|
+
}
|
|
122
|
+
if (answer.type === 'score') {
|
|
123
|
+
const [top1, top2 = 0] = sortedProbabilities(answer.probabilities);
|
|
124
|
+
if (typeof answer.score !== 'number' || !Number.isFinite(answer.score)) {
|
|
125
|
+
throw new TypeError('computeConfidenceMargin: a "score" answer must carry a finite score');
|
|
126
|
+
}
|
|
127
|
+
return { confidence: top1, margin: top1 - top2, value: answer.score };
|
|
128
|
+
}
|
|
129
|
+
if (answer.type === 'noul') {
|
|
130
|
+
if (typeof answer.noul !== 'number' || !Number.isFinite(answer.noul) || answer.noul < 0 || answer.noul > 1) {
|
|
131
|
+
throw new TypeError('computeConfidenceMargin: a "noul" answer must carry noul in [0, 1]');
|
|
132
|
+
}
|
|
133
|
+
const p = answer.noul;
|
|
134
|
+
return { confidence: Math.max(p, 1 - p), margin: Math.abs(2 * p - 1), value: p >= 0.5 };
|
|
135
|
+
}
|
|
136
|
+
throw new TypeError(`computeConfidenceMargin: unknown answer.type ${JSON.stringify(answer?.type)}`);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* @typedef {object} Decision
|
|
141
|
+
* @property {boolean} decided - true only for the `'act'` stage.
|
|
142
|
+
* @property {'act'|'check'|'decide'} stage
|
|
143
|
+
* @property {string|number|boolean} value - the S1-computed answer, independent of `stage`.
|
|
144
|
+
* @property {number} confidence
|
|
145
|
+
* @property {number} margin
|
|
146
|
+
*/
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* The general act/check/decide band (§3.3) for one question's answer. Does NOT apply to
|
|
150
|
+
* `defect`/`resolved` FINDING triage — use `bandFinding`/`bandResolved` for those.
|
|
151
|
+
* @param {string} questionId
|
|
152
|
+
* @param {any} answer - see {@link computeConfidenceMargin} for why this isn't `JevAnswer`.
|
|
153
|
+
* @param {Record<string, any>} [cfg]
|
|
154
|
+
* @returns {Decision}
|
|
155
|
+
*/
|
|
156
|
+
export function decide(questionId, answer, cfg = {}) {
|
|
157
|
+
const { confidence, margin, value } = computeConfidenceMargin(answer);
|
|
158
|
+
const band = bandsFor(questionId, cfg);
|
|
159
|
+
/** @type {'act'|'check'|'decide'} */
|
|
160
|
+
let stage;
|
|
161
|
+
if (confidence >= band.act && margin >= band.close_margin) {
|
|
162
|
+
stage = 'act';
|
|
163
|
+
} else if (confidence >= band.check) {
|
|
164
|
+
stage = 'check';
|
|
165
|
+
} else {
|
|
166
|
+
stage = 'decide';
|
|
167
|
+
}
|
|
168
|
+
return { decided: stage === 'act', stage, value, confidence, margin };
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Finding triage band for a `defect` answer's raw `noul` PROBABILITY (§3.3, §4.2 step 4).
|
|
173
|
+
* @param {number} p - `answer.noul`, the probability the finding is real (NOT a confidence).
|
|
174
|
+
* @param {Record<string, any>} [cfg]
|
|
175
|
+
* @returns {'fix_now'|'judge'|'nit'}
|
|
176
|
+
*/
|
|
177
|
+
export function bandFinding(p, cfg = {}) {
|
|
178
|
+
if (typeof p !== 'number' || !Number.isFinite(p) || p < 0 || p > 1) {
|
|
179
|
+
throw new TypeError('bandFinding: p must be a finite number in [0, 1]');
|
|
180
|
+
}
|
|
181
|
+
const t = findingsThresholds(cfg);
|
|
182
|
+
if (p >= t.fix) return 'fix_now';
|
|
183
|
+
if (p >= t.nit) return 'judge';
|
|
184
|
+
return 'nit';
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* Finding triage band for a `resolved` answer's raw `noul` probability (§3.3).
|
|
189
|
+
* @param {number} p
|
|
190
|
+
* @param {Record<string, any>} [cfg]
|
|
191
|
+
* @returns {'closed'|'recheck'}
|
|
192
|
+
*/
|
|
193
|
+
export function bandResolved(p, cfg = {}) {
|
|
194
|
+
if (typeof p !== 'number' || !Number.isFinite(p) || p < 0 || p > 1) {
|
|
195
|
+
throw new TypeError('bandResolved: p must be a finite number in [0, 1]');
|
|
196
|
+
}
|
|
197
|
+
const t = findingsThresholds(cfg);
|
|
198
|
+
return p >= t.resolved ? 'closed' : 'recheck';
|
|
199
|
+
}
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `code-forge doctor` (plan §2.3; block B13b): every row, in print order. `--quick` = the three
|
|
3
|
+
* rows the skill's preflight needs (config, harness links, Jev key resolution); the full run adds
|
|
4
|
+
* the PATH/Solo/gate rows, the live Jev call, ledger, `tmp`, the provider CLI probes (presence,
|
|
5
|
+
* version, flags, one call per role builder, isolation, path deny, Codex rules), the worker
|
|
6
|
+
* start/stop, the signer key and the `signer: same-user boundary only` line.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { randomBytes } from 'node:crypto';
|
|
10
|
+
import { mkdirSync, rmSync } from 'node:fs';
|
|
11
|
+
import os from 'node:os';
|
|
12
|
+
import path from 'node:path';
|
|
13
|
+
import { cliNameForProvider } from '../engines/provider-cli.mjs';
|
|
14
|
+
import { newRunId } from '../state/paths.mjs';
|
|
15
|
+
import { runRoot } from '../util/tmp.mjs';
|
|
16
|
+
import { checkCommands, checkConfig, checkJev, checkKeys, checkLedger, checkLinks, checkTmp } from './local.mjs';
|
|
17
|
+
import { configuredProviders, pingRoles, probeCli, probeCodexRules, probeIsolation, probePathDeny } from './probes.mjs';
|
|
18
|
+
import { row } from './rows.mjs';
|
|
19
|
+
import { probeWorkerAndSigner, SIGNER_ROW } from './worker-probe.mjs';
|
|
20
|
+
|
|
21
|
+
/** @typedef {import('./rows.mjs').Row} Row */
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* @typedef {object} DoctorDeps
|
|
25
|
+
* @property {NodeJS.ProcessEnv} [env] - the env probes and spawned CLIs see (default `process.env`).
|
|
26
|
+
* @property {Partial<Record<'claude'|'codex'|'grok', string>>} [bins] - replaces each provider CLI (tests: fakes).
|
|
27
|
+
* @property {any} [store] - B2 key store (default: the production chain).
|
|
28
|
+
* @property {any} [opRead]
|
|
29
|
+
* @property {typeof import('../decide/jev-client.mjs').askJev} [askJev]
|
|
30
|
+
* @property {string} [canary] - the path-deny canary (tests).
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
/** @param {Record<string, any> | null} cfg @returns {string} a new, private run root for the probes. */
|
|
34
|
+
function doctorRoot(cfg) {
|
|
35
|
+
const root = typeof cfg?.tmp?.root === 'string' ? cfg.tmp.root : undefined;
|
|
36
|
+
return runRoot(`doctor-${process.pid}-${Date.now()}-${randomBytes(3).toString('hex')}`, { root });
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* @param {{cwd?: string, quick?: boolean, runRootDir?: string}} opts - `runRootDir`: the caller's
|
|
41
|
+
* run root (the CLI adopts one); otherwise the doctor makes one and removes it afterwards.
|
|
42
|
+
* @param {DoctorDeps} [deps]
|
|
43
|
+
* @returns {Promise<Row[]>}
|
|
44
|
+
*/
|
|
45
|
+
export async function runDoctor(opts = {}, deps = {}) {
|
|
46
|
+
const env = deps.env ?? process.env;
|
|
47
|
+
const cwd = path.resolve(opts.cwd ?? process.cwd());
|
|
48
|
+
const checked = await checkConfig(cwd);
|
|
49
|
+
const cfg = checked.cfg;
|
|
50
|
+
const links = await checkLinks(env.HOME || os.homedir());
|
|
51
|
+
const keys = await checkKeys(cfg, { env, store: deps.store, opRead: deps.opRead });
|
|
52
|
+
/** @type {Row[]} */
|
|
53
|
+
const rows = [...checked.rows, ...links, ...keys.rows];
|
|
54
|
+
if (opts.quick) return rows;
|
|
55
|
+
|
|
56
|
+
if (cfg) {
|
|
57
|
+
rows.push(...(await checkCommands(cfg, env.PATH ?? '')));
|
|
58
|
+
rows.push(...(await checkJev(cfg, keys.key, { askJev: deps.askJev })));
|
|
59
|
+
}
|
|
60
|
+
rows.push(...(await checkLedger()));
|
|
61
|
+
rows.push(...checkTmp(cfg));
|
|
62
|
+
if (!cfg) {
|
|
63
|
+
rows.push(row('probes', 'FAIL', 'probes', 'skipped: no valid config'));
|
|
64
|
+
rows.push(SIGNER_ROW);
|
|
65
|
+
return rows;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const ownRoot = opts.runRootDir === undefined;
|
|
69
|
+
let runRootDir;
|
|
70
|
+
try {
|
|
71
|
+
runRootDir = opts.runRootDir ?? doctorRoot(cfg);
|
|
72
|
+
} catch (err) {
|
|
73
|
+
rows.push(row('probes', 'FAIL', 'probes', `no temp root (${err?.message ?? 'error'})`));
|
|
74
|
+
rows.push(SIGNER_ROW);
|
|
75
|
+
return rows;
|
|
76
|
+
}
|
|
77
|
+
const workDir = path.join(runRootDir, 'doctor');
|
|
78
|
+
mkdirSync(workDir, { recursive: true, mode: 0o700 });
|
|
79
|
+
try {
|
|
80
|
+
const ctx = { cfg, cwd, workDir, runRootDir, env, bins: deps.bins, canary: deps.canary };
|
|
81
|
+
const missing = new Set();
|
|
82
|
+
for (const provider of configuredProviders(cfg)) {
|
|
83
|
+
const cli = cliNameForProvider(provider);
|
|
84
|
+
if (!cli) {
|
|
85
|
+
rows.push(row(`cli.${provider}`, 'FAIL', provider, 'no CLI mapping for this provider'));
|
|
86
|
+
missing.add(provider);
|
|
87
|
+
continue;
|
|
88
|
+
}
|
|
89
|
+
const probed = await probeCli(ctx, cli);
|
|
90
|
+
rows.push(...probed.rows);
|
|
91
|
+
if (!probed.present) missing.add(provider);
|
|
92
|
+
}
|
|
93
|
+
rows.push(...(await pingRoles(ctx, missing)));
|
|
94
|
+
const reviewerProvider = cfg.levels?.L2?.provider ?? cfg.provider;
|
|
95
|
+
const coderProvider = cfg.levels?.L1?.provider ?? cfg.provider;
|
|
96
|
+
rows.push(...(missing.has(reviewerProvider) ? [row('isolation', 'FAIL', 'isolation', 'skipped (reviewer CLI missing)')] : await probeIsolation(ctx)));
|
|
97
|
+
rows.push(...(missing.has(coderProvider) ? [row('path-deny', 'WARN', 'path deny', 'skipped (coder CLI missing)')] : await probePathDeny(ctx)));
|
|
98
|
+
if (configuredProviders(cfg).includes('openai')) rows.push(...probeCodexRules(ctx));
|
|
99
|
+
rows.push(...(await probeWorkerAndSigner({ cfg, configFile: checked.file, workDir, runRootDir, env, runId: newRunId() })));
|
|
100
|
+
} finally {
|
|
101
|
+
if (ownRoot) rmSync(runRootDir, { recursive: true, force: true });
|
|
102
|
+
else rmSync(workDir, { recursive: true, force: true });
|
|
103
|
+
}
|
|
104
|
+
return rows;
|
|
105
|
+
}
|