@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,264 @@
1
+ /**
2
+ * The review engine (plan §4.2 adaptive, §4.4 consensus, §4.3 isolation; block B12a).
3
+ *
4
+ * `reviewFile(input, deps)` reviews ONE file at its current content:
5
+ * 1. plans the sessions (`planSessions`):
6
+ * adaptive (default) — `risk < 1` ⇒ one L2 `quick`; `1 ≤ risk < 2` ⇒ one L2 `full`;
7
+ * `risk ≥ 2` ⇒ two blind L2 sessions (lens `A`, lens `B`) + one L3 `judge`;
8
+ * consensus (`review.multimodel: true`) — one L2 `full` per EFFECTIVE provider
9
+ * (`resolve(L2)` and `review.second_levels.L2` / `review.second_provider`) + one L3 judge;
10
+ * the same effective provider twice is refused (`consensus-same-provider`);
11
+ * 2. reads the diff once and assembles one packet per lens (`packet.mjs`);
12
+ * 3. runs every session through `deps.spawn` — a fresh closed-book process in an empty cwd with
13
+ * the packet on stdin (`spawnSession`, B9a; `--safe-mode --tools ""` from the B4 builder);
14
+ * 4. passes each answer through the stub guard (`validate-review.mjs`); an answer that fails it
15
+ * is `unavailable` with its reason and is NEVER approval; a failed lens means no judge;
16
+ * 5. a valid answer with `needs_file` gets ONE more round with those files attached (git-tracked
17
+ * files only, never secret-like paths — `attachFiles`); a second `needs_file` is ignored; a
18
+ * request with a non-repo-relative path is refused (`needs_file-refused`, never approval).
19
+ * Approval (until B12b's triage lands) = the final answer (the judge's in dual/consensus mode, else
20
+ * the single reviewer's) passed the guard, says `passed: true`, and carries no `critical` or
21
+ * `warning` finding.
22
+ */
23
+
24
+ import { mkdirSync, rmSync, writeFileSync } from 'node:fs';
25
+ import { randomBytes } from 'node:crypto';
26
+ import path from 'node:path';
27
+ import { resolveLevel } from '../config/known-ids.mjs';
28
+ import { fallbackRisk } from '../decide/fallback-rules.mjs';
29
+ import { tierFor } from '../proof/tiers.mjs';
30
+ import { assembleJudgePacket, assemblePacket, attachFiles, budgetFor, readFileDiff } from './packet.mjs';
31
+ import { FINDING_SCHEMA, minTokensOut, validateReview } from './validate-review.mjs';
32
+ import { assertRowPath } from '../worker/ticket.mjs';
33
+
34
+ /**
35
+ * @typedef {{lens: 'quick' | 'full' | 'A' | 'B' | 'judge', role: 'reviewer' | 'judge', level: 'L2' | 'L3', slot: 'A' | 'B' | null, cfg?: Record<string, any>}} SessionSpec -
36
+ * `cfg`: a per-session config (the consensus second reviewer: `levels.L2` = its effective level).
37
+ * @typedef {{mode: 'adaptive' | 'consensus', depth: 'quick' | 'full' | 'dual' | 'consensus' | null, sessions: SessionSpec[], refused?: string}} SessionPlan
38
+ */
39
+
40
+ /**
41
+ * The second reviewer's effective L2 level for consensus mode.
42
+ * @param {Record<string, any>} cfg
43
+ * @returns {{ok: boolean, level?: Record<string, any>, primary?: string, reason?: string}}
44
+ */
45
+ export function secondLevel(cfg) {
46
+ let primary;
47
+ try {
48
+ primary = resolveLevel(cfg, 'L2').provider;
49
+ } catch {
50
+ return { ok: false, reason: 'consensus-no-l2' };
51
+ }
52
+ const override = cfg?.review?.second_levels?.L2 ?? null;
53
+ const provider = override?.provider ?? cfg?.review?.second_provider ?? null;
54
+ if (typeof provider !== 'string' || provider.length === 0) return { ok: false, reason: 'consensus-no-second-provider' };
55
+ if (provider === primary) return { ok: false, reason: 'consensus-same-provider' };
56
+ if (typeof override?.model !== 'string' || override.model.length === 0) return { ok: false, reason: 'consensus-no-second-model' };
57
+ // the second reviewer's fallback ladder never lands on the first reviewer's provider
58
+ const fallback = (Array.isArray(override.fallback) ? override.fallback : []).filter((/** @type {any} */ f) => f?.provider !== primary);
59
+ return { ok: true, level: { ...override, provider, fallback }, primary };
60
+ }
61
+
62
+ /**
63
+ * @param {{risk: number, cfg: Record<string, any>}} opts
64
+ * @returns {SessionPlan}
65
+ */
66
+ export function planSessions({ risk, cfg }) {
67
+ if (typeof risk !== 'number' || !Number.isFinite(risk) || risk < 0 || risk > 3) throw new TypeError('planSessions: risk must be a number from 0 to 3');
68
+ const judge = /** @type {SessionSpec} */ ({ lens: 'judge', role: 'judge', level: 'L3', slot: null });
69
+ if (cfg?.review?.multimodel === true) {
70
+ const second = secondLevel(cfg);
71
+ if (!second.ok) return { mode: 'consensus', depth: null, sessions: [], refused: second.reason };
72
+ return {
73
+ mode: 'consensus',
74
+ depth: 'consensus',
75
+ sessions: [
76
+ { lens: 'full', role: 'reviewer', level: 'L2', slot: 'A' },
77
+ { lens: 'full', role: 'reviewer', level: 'L2', slot: 'B', cfg: { ...cfg, levels: { ...cfg.levels, L2: second.level } } },
78
+ judge,
79
+ ],
80
+ };
81
+ }
82
+ if (risk < 1) return { mode: 'adaptive', depth: 'quick', sessions: [{ lens: 'quick', role: 'reviewer', level: 'L2', slot: null }] };
83
+ if (risk < 2) return { mode: 'adaptive', depth: 'full', sessions: [{ lens: 'full', role: 'reviewer', level: 'L2', slot: null }] };
84
+ return {
85
+ mode: 'adaptive',
86
+ depth: 'dual',
87
+ sessions: [
88
+ { lens: 'A', role: 'reviewer', level: 'L2', slot: 'A' },
89
+ { lens: 'B', role: 'reviewer', level: 'L2', slot: 'B' },
90
+ judge,
91
+ ],
92
+ };
93
+ }
94
+
95
+ /**
96
+ * The rules risk (§3.5 fallback; S1 `risk` without Jev): a `proof.tiers.high.paths` match ⇒ 3,
97
+ * a migration/policy/middleware path ⇒ 2, more than 200 added lines ⇒ 1, else 0.
98
+ * @param {{file: string, plusCount: number, cfg: Record<string, any>}} opts
99
+ * @returns {number}
100
+ */
101
+ export function rulesRisk({ file, plusCount, cfg }) {
102
+ const highPaths = cfg?.proof?.tiers?.high?.paths;
103
+ const pathFloorHit = tierFor({ file, risk: 0, highPaths: Array.isArray(highPaths) ? highPaths : [] }).reason === 'path';
104
+ return fallbackRisk({
105
+ pathFloorHit,
106
+ filesChanged: 1,
107
+ linesAdded: plusCount,
108
+ touchesMigration: /migration/i.test(file),
109
+ touchesPolicyOrMiddleware: /polic(y|ies)|middleware/i.test(file),
110
+ });
111
+ }
112
+
113
+ /**
114
+ * @typedef {object} ReviewInput
115
+ * @property {string} repoRoot - realpath'd repository root.
116
+ * @property {string} file - repo-root-relative.
117
+ * @property {string | null} [base] - the block's base SHA (null ⇒ HEAD).
118
+ * @property {Record<string, any>} cfg
119
+ * @property {number} [risk] - the S1 risk (0–3); absent ⇒ the rules risk.
120
+ * @property {string} [rulesDigest] @property {string} [factsExcerpt]
121
+ * @property {string} workDir - where packet files are written (under the run's temp root).
122
+ */
123
+
124
+ /**
125
+ * @typedef {object} ReviewDeps
126
+ * @property {(opts: Record<string, any>) => Promise<Record<string, any>>} spawn - one isolated
127
+ * session (`spawnSession` opts minus the worker-pinned ones); a per-call `cfg` selects another
128
+ * provider's level for the same slot (consensus).
129
+ * @property {(row: Record<string, any>) => Promise<unknown>} [writeRow] - ledger rows
130
+ * (`review.plan`, `review.unavailable`); a failed write never fails the review.
131
+ */
132
+
133
+ /** @param {ReviewDeps} deps @param {Record<string, any>} row */
134
+ async function note(deps, row) {
135
+ try {
136
+ await deps.writeRow?.(row);
137
+ } catch {
138
+ // the review result stands; the ledger row is best-effort
139
+ }
140
+ }
141
+
142
+ /**
143
+ * The `needs_file` request of a model answer, or null when any entry is not a plain
144
+ * repo-relative path (absolute, `..`, `.git/`, `.code-forge/`, not a string).
145
+ * @param {unknown} needs @returns {string[] | null}
146
+ */
147
+ function safeNeeds(needs) {
148
+ if (!Array.isArray(needs)) return null;
149
+ try {
150
+ return needs.map((p) => assertRowPath(p));
151
+ } catch {
152
+ return null;
153
+ }
154
+ }
155
+
156
+ /**
157
+ * @param {ReviewInput} input @param {ReviewDeps} deps
158
+ * @returns {Promise<{status: string, approved: boolean, engine: string, sessions: Array<Record<string, any>>} & Record<string, any>>}
159
+ */
160
+ export async function reviewFile(input, deps) {
161
+ const { repoRoot, cfg, workDir } = input;
162
+ const diff = await readFileDiff({ repoRoot, file: input.file, base: input.base ?? null });
163
+ const risk = input.risk ?? rulesRisk({ file: diff.file, plusCount: diff.plusCount, cfg });
164
+ const plan = planSessions({ risk, cfg });
165
+ const head = { engine: plan.mode, depth: plan.depth, risk, file: diff.file };
166
+ if (plan.refused) return { status: 'refused', reason: plan.refused, approved: false, ...head, sessions: [] };
167
+ if (diff.diffText.trim().length === 0) return { status: 'no_change', approved: false, ...head, sessions: [] };
168
+ mkdirSync(workDir, { recursive: true, mode: 0o700 });
169
+ const min = minTokensOut(cfg);
170
+
171
+ /** @param {SessionSpec} spec @param {{text: string, hunkHeaders: string[], tokensIn: number, contextMode: string | null}} packet */
172
+ const run = async (spec, packet) => {
173
+ /** @type {Record<string, any>} */
174
+ let summary = await runOne(spec, packet.text, packet, { deps, workDir, min, cfg });
175
+ const needs = summary.review?.needs_file;
176
+ if (spec.role === 'reviewer' && summary.status === 'ok' && Array.isArray(needs) && needs.length > 0) {
177
+ const paths = safeNeeds(needs);
178
+ let extra = null;
179
+ try {
180
+ extra = paths ? await attachFiles({ repoRoot, paths, budgetTokens: budgetFor(cfg, 'full_in') }) : null;
181
+ } catch {
182
+ extra = null;
183
+ }
184
+ if (extra === null) return { ...summary, status: 'unavailable', reason: 'needs_file-refused', review: null };
185
+ summary = { ...(await runOne(spec, `${packet.text}${extra}`, packet, { deps, workDir, min, cfg })), needs_file_round: true };
186
+ }
187
+ return summary;
188
+ };
189
+
190
+ const reviewers = plan.sessions.filter((s) => s.role === 'reviewer');
191
+ const packets = reviewers.map((spec) => assemblePacket({ diff, lens: spec.lens, rulesDigest: input.rulesDigest, factsExcerpt: input.factsExcerpt, cfg }));
192
+ const split = packets.find((p) => p.status === 'split_required');
193
+ if (split) return { status: 'split_required', approved: false, ...head, tokens_in: split.tokensIn, budget: split.budget, sessions: [] };
194
+ const firstPacket = /** @type {any} */ (packets[0]);
195
+ await note(deps, { event: 'review.plan', depth_unconstrained: plan.depth, depth_chosen: plan.depth, degrade_step: 0, risk, context_mode: firstPacket?.contextMode ?? null, forecast_tokens: packets.reduce((n, p) => n + p.tokensIn, 0) });
196
+ const done = await Promise.all(reviewers.map((spec, i) => run(spec, /** @type {any} */ (packets[i]))));
197
+ const sessions = done.map(publicSummary);
198
+ const failed = done.find((s) => s.status !== 'ok');
199
+ if (failed) {
200
+ await note(deps, { event: 'review.unavailable', reason: failed.reason, lens: failed.lens });
201
+ return { status: 'unavailable', reason: failed.reason, approved: false, ...head, sessions };
202
+ }
203
+
204
+ let final = done[0];
205
+ const judgeSpec = plan.sessions.find((s) => s.role === 'judge');
206
+ if (judgeSpec) {
207
+ const bySlot = Object.fromEntries(done.map((s) => [s.slot, s.review]));
208
+ const packet = assembleJudgePacket({ diff, reports: { A: bySlot.A, B: bySlot.B }, cfg });
209
+ final = await runOne(judgeSpec, packet.text, packet, { deps, workDir, min, cfg });
210
+ sessions.push(publicSummary(final));
211
+ if (final.status !== 'ok') {
212
+ await note(deps, { event: 'review.unavailable', reason: final.reason, lens: 'judge' });
213
+ return { status: 'unavailable', reason: final.reason, approved: false, ...head, sessions };
214
+ }
215
+ }
216
+ const findings = final.review.findings;
217
+ const approved = final.review.passed === true && findings.every((/** @type {any} */ f) => f.severity === 'nit');
218
+ return { status: 'reviewed', approved, ...head, summary: final.review.summary, findings, sessions };
219
+ }
220
+
221
+ /**
222
+ * One session: packet file (0600, removed after), spawn, stub guard.
223
+ * @param {SessionSpec} spec @param {string} text
224
+ * @param {{hunkHeaders: string[], tokensIn: number, contextMode: string | null}} packet
225
+ * @param {{deps: ReviewDeps, workDir: string, min: number, cfg: Record<string, any>}} env
226
+ */
227
+ async function runOne(spec, text, packet, { deps, workDir, min }) {
228
+ const promptPath = path.join(workDir, `${spec.lens}-${randomBytes(6).toString('hex')}.md`);
229
+ const base = { lens: spec.lens, role: spec.role, level: spec.level, slot: spec.slot, context_mode: packet.contextMode, ctx_tokens_in: packet.tokensIn };
230
+ writeFileSync(promptPath, text, { mode: 0o600 });
231
+ /** @type {Record<string, any> | null} */
232
+ let res = null;
233
+ try {
234
+ res = await deps.spawn({
235
+ level: spec.level,
236
+ role: spec.role,
237
+ promptPath,
238
+ schema: FINDING_SCHEMA,
239
+ rowExtra: { lens: spec.lens, context_mode: packet.contextMode, ctx_tokens_in: packet.tokensIn },
240
+ ...(spec.cfg ? { cfg: spec.cfg } : {}),
241
+ });
242
+ } catch {
243
+ res = null; // a spawn that throws (bad config, refused argv) is an `exit` failure, never approval
244
+ } finally {
245
+ rmSync(promptPath, { force: true });
246
+ }
247
+ const verdict = validateReview(res, { hunkHeaders: packet.hunkHeaders, minTokensOut: min });
248
+ const meta = {
249
+ ...base,
250
+ provider: res?.provider ?? null,
251
+ model: res?.model ?? null,
252
+ fallback_step: res?.fallback_step ?? 0,
253
+ tokens_in: res?.usage?.tokens_in ?? null,
254
+ tokens_out: res?.usage?.tokens_out ?? null,
255
+ };
256
+ if (!verdict.ok) return { ...meta, status: 'unavailable', reason: verdict.reason, review: null };
257
+ return { ...meta, status: 'ok', reason: null, review: verdict.review };
258
+ }
259
+
260
+ /** The per-session summary the worker signs: the answer's verdict and findings, never the packet. */
261
+ function publicSummary(/** @type {Record<string, any>} */ s) {
262
+ const { review, ...rest } = s;
263
+ return review ? { ...rest, passed: review.passed === true, findings: review.findings } : rest;
264
+ }
@@ -0,0 +1,47 @@
1
+ {
2
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
3
+ "$id": "https://code-forge.local/schema/review/finding.schema.json",
4
+ "title": "code-forge review answer (plan §4.2 finding schema)",
5
+ "type": "object",
6
+ "additionalProperties": false,
7
+ "required": ["passed", "summary", "reviewed_hunks", "findings"],
8
+ "properties": {
9
+ "passed": { "type": "boolean" },
10
+ "summary": { "type": "string", "maxLength": 300 },
11
+ "reviewed_hunks": { "type": "array", "items": { "type": "string" } },
12
+ "findings": {
13
+ "type": "array",
14
+ "items": {
15
+ "type": "object",
16
+ "additionalProperties": false,
17
+ "required": ["id", "file", "line_start", "line_end", "severity", "category", "claim", "evidence", "fix"],
18
+ "properties": {
19
+ "id": { "type": "string", "minLength": 1, "maxLength": 64 },
20
+ "file": { "type": "string" },
21
+ "line_start": { "type": "integer", "minimum": 0 },
22
+ "line_end": { "type": "integer", "minimum": 0 },
23
+ "severity": { "type": "string", "enum": ["critical", "warning", "nit"] },
24
+ "category": { "type": "string", "maxLength": 64 },
25
+ "claim": { "type": "string", "maxLength": 300 },
26
+ "evidence": { "type": "string", "maxLength": 300 },
27
+ "fix": { "type": "string", "maxLength": 300 },
28
+ "agreement": { "type": "string", "enum": ["both", "A-only", "B-only"] }
29
+ }
30
+ }
31
+ },
32
+ "resolved": {
33
+ "type": "array",
34
+ "items": {
35
+ "type": "object",
36
+ "additionalProperties": false,
37
+ "required": ["id", "resolved", "why"],
38
+ "properties": {
39
+ "id": { "type": "string" },
40
+ "resolved": { "type": "boolean" },
41
+ "why": { "type": "string", "maxLength": 200 }
42
+ }
43
+ }
44
+ },
45
+ "needs_file": { "type": "array", "items": { "type": "string" } }
46
+ }
47
+ }