@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,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
+ }