@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,355 @@
1
+ /**
2
+ * The review packet (plan §4.1, §4.3, V3, V4; block B12a).
3
+ *
4
+ * A reviewer session is closed-book: the packet is everything it sees, piped to its stdin by the
5
+ * spawner. Sections, in FIXED order, so the prefix (lens, rules digest, facts) is byte-identical
6
+ * across the files of a block and provider prompt caching applies:
7
+ *
8
+ * # code-forge review packet
9
+ * ## lens the lens file (`lenses/<name>.md`)
10
+ * ## project rules the rules digest (≤ 60 lines)
11
+ * ## facts the facts-sheet excerpt
12
+ * ## context the current file (whole, or hunk windows — `context.mjs`)
13
+ * ## diff `file:`, the `hunks:` list the reviewer must echo, then the diff
14
+ *
15
+ * Diff source (§4.1): a file in the index is diffed against the block's base SHA
16
+ * (`git diff <base> -- <file>`); a file on disk that is not in the index is new
17
+ * (`git diff --no-index -- /dev/null <file>`). A tracked file with no change yields no packet.
18
+ *
19
+ * Budget (§4.3): `review.budgets.full_in` (`quick_in` for the quick lens), in tokens estimated as
20
+ * bytes / 4. A diff that alone exceeds it ⇒ `split_required`. Otherwise over budget ⇒ the context
21
+ * shrinks to ± `min_context_lines` (`minimal`) FIRST, then the digest is trimmed from the end;
22
+ * the diff is never cut.
23
+ *
24
+ * Path rule (V4): `file` is repo-root-relative; `./`, `../`, absolute values, symlinks and paths
25
+ * under `.git/` or `.code-forge/` are refused with `bad-path` before git runs. So is anything git
26
+ * could read as pathspec magic (`*`, `?`, `[`, `]`, a leading `:`), and every child git here runs
27
+ * with `GIT_LITERAL_PATHSPECS=1`, so one `file` is always one file. A secret-like name (`.env`,
28
+ * `*.pem`, `id_rsa`, …) or an untracked file git ignores never becomes a "new file" packet.
29
+ */
30
+
31
+ import { existsSync, lstatSync, readFileSync } from 'node:fs';
32
+ import path from 'node:path';
33
+ import { fileURLToPath } from 'node:url';
34
+ import { exec } from '../util/exec.mjs';
35
+ import { assertInsideRoot, assertRowPath, gitChildEnv, readRegularFileNoFollow, WorkerError } from '../worker/ticket.mjs';
36
+ import { buildContext, parseDiff, splitLines } from './context.mjs';
37
+
38
+ export const LENSES = Object.freeze(['quick', 'full', 'recheck', 'A', 'B', 'judge']);
39
+
40
+ /** The rules digest never exceeds this many lines (§4.3). */
41
+ export const DIGEST_MAX_LINES = 60;
42
+
43
+ export const DEFAULT_BUDGETS = Object.freeze({ quick_in: 6000, full_in: 12000, judge_in: 8000 });
44
+
45
+ const LENS_DIR = path.join(path.dirname(fileURLToPath(import.meta.url)), 'lenses');
46
+ const GIT_TIMEOUT_MS = 30000;
47
+
48
+ /** Characters git reads as pathspec magic (globs); a leading `:` opens a magic signature. */
49
+ const PATHSPEC_MAGIC = /[*?[\]]/;
50
+
51
+ /** Paths never attached, whatever git says: env files, keys, certificates, credentials, our own state. */
52
+ const SECRET_LIKE = [/(^|\/)\.env[^/]*$/i, /\.pem$/i, /\.key$/i, /\.p12$/i, /(^|\/)id_(rsa|ed25519|ecdsa)[^/]*$/i, /(^|\/)credentials[^/]*$/i, /(^|\/)\.code-forge(\/|$)/i];
53
+
54
+ /** @param {string} rel @returns {boolean} */
55
+ const isSecretLike = (rel) => SECRET_LIKE.some((re) => re.test(rel));
56
+
57
+ /** The env for every child git in this module: B11's clean env plus literal pathspecs. */
58
+ const reviewGitEnv = () => ({ ...gitChildEnv(), GIT_LITERAL_PATHSPECS: '1' });
59
+
60
+ /** A refusal with a stable `code`: `bad-path`, `bad-lens`, `git`. */
61
+ export class PacketError extends Error {
62
+ /** @param {string} code @param {string} message */
63
+ constructor(code, message) {
64
+ super(message);
65
+ this.name = 'PacketError';
66
+ this.code = code;
67
+ }
68
+ }
69
+
70
+ /** @param {string} text @returns {number} estimated tokens (bytes / 4, rounded up). */
71
+ export const estimateTokens = (text) => Math.ceil(Buffer.byteLength(text) / 4);
72
+
73
+ /** @param {string} name @returns {string} the lens text. */
74
+ export function loadLens(name) {
75
+ if (!LENSES.includes(name)) throw new PacketError('bad-lens', `unknown lens ${JSON.stringify(name)}`);
76
+ return readFileSync(path.join(LENS_DIR, `${name}.md`), 'utf8').trimEnd();
77
+ }
78
+
79
+ /**
80
+ * @param {Record<string, any> | undefined} cfg @param {'quick_in' | 'full_in' | 'judge_in'} key
81
+ * @returns {number}
82
+ */
83
+ export function budgetFor(cfg, key) {
84
+ const v = cfg?.review?.budgets?.[key];
85
+ return Number.isInteger(v) && v > 0 ? v : DEFAULT_BUDGETS[key];
86
+ }
87
+
88
+ /**
89
+ * Refuse a `file` value that is not a plain repo-root-relative path (V4): the string shape first
90
+ * (`assertRowPath`), then the disk — the joined path, when it exists, is `lstat`ed and a symlink is
91
+ * refused (never followed: `readFileSync` would hand the reviewer the target's bytes), and its
92
+ * realpath'd parent must stay inside the root (`assertInsideRoot`). No git call happens before.
93
+ * @param {string} repoRoot @param {unknown} file @returns {string}
94
+ * @throws {PacketError} `bad-path`
95
+ */
96
+ export function assertPacketPath(repoRoot, file) {
97
+ let rel;
98
+ try {
99
+ rel = assertRowPath(file);
100
+ } catch (err) {
101
+ if (err instanceof WorkerError) throw new PacketError('bad-path', err.message);
102
+ throw err;
103
+ }
104
+ if (PATHSPEC_MAGIC.test(rel) || rel.startsWith(':')) throw new PacketError('bad-path', 'file may not contain glob characters or start with ":"');
105
+ /** @type {import('node:fs').Stats | null} */
106
+ let st = null;
107
+ try {
108
+ st = lstatSync(path.join(repoRoot, rel));
109
+ } catch {
110
+ // missing: a deletion is reviewable; the parent is still checked below
111
+ }
112
+ if (st?.isSymbolicLink()) throw new PacketError('bad-path', 'the path is a symlink');
113
+ // a directory (or device, socket, …) is not one reviewable file: git would diff everything under it
114
+ if (st && !st.isFile()) throw new PacketError('bad-path', 'not a regular file');
115
+ try {
116
+ return assertInsideRoot(repoRoot, rel);
117
+ } catch (err) {
118
+ if (err instanceof WorkerError) throw new PacketError('bad-path', err.message);
119
+ throw err;
120
+ }
121
+ }
122
+
123
+ /**
124
+ * @typedef {object} FileDiff
125
+ * @property {string} file
126
+ * @property {'tracked' | 'new'} kind
127
+ * @property {string} diffText
128
+ * @property {string | null} content - the current content, null when the file is deleted.
129
+ * @property {import('./context.mjs').Hunk[]} hunks
130
+ * @property {number} plusCount
131
+ * @property {number} minusCount
132
+ */
133
+
134
+ /**
135
+ * Read one file's diff against `base` (§4.1).
136
+ * @param {{repoRoot: string, file: string, base?: string | null}} opts
137
+ * @returns {Promise<FileDiff>}
138
+ * @throws {PacketError} `bad-path`, `git`
139
+ */
140
+ export async function readFileDiff({ repoRoot, file, base }) {
141
+ const rel = assertPacketPath(repoRoot, file);
142
+ if (isSecretLike(rel)) throw new PacketError('bad-path', 'secret-like path');
143
+ const ref = base ?? 'HEAD';
144
+ if (typeof ref !== 'string' || ref.length === 0 || ref.startsWith('-')) throw new PacketError('git', 'the base must be a commit id');
145
+ const env = reviewGitEnv();
146
+ const listed = await exec(['git', 'ls-files', '-z', '--', rel], { cwd: repoRoot, env, timeoutMs: GIT_TIMEOUT_MS });
147
+ if (listed.result !== 'ok') throw new PacketError('git', `git ls-files failed (exit ${listed.code})`);
148
+ const full = path.join(repoRoot, rel);
149
+ const onDisk = existsSync(full);
150
+ const kind = listed.stdout.length === 0 && onDisk ? 'new' : 'tracked';
151
+ if (kind === 'new') {
152
+ // An untracked file git ignores (a local `.env`, a build secret) is never a "new file".
153
+ // `check-ignore` refuses the `literal` pathspec magic ("not supported by this command"), so it
154
+ // runs on the plain env: `rel` has no glob character and no leading `:` (refused above).
155
+ const ignored = await exec(['git', 'check-ignore', '-q', '--', rel], { cwd: repoRoot, env: gitChildEnv(), timeoutMs: GIT_TIMEOUT_MS, okExitCodes: [0, 1] });
156
+ if (ignored.result !== 'ok') throw new PacketError('git', `git check-ignore failed (exit ${ignored.code})`);
157
+ if (ignored.code === 0) throw new PacketError('bad-path', 'the file is untracked and gitignored');
158
+ }
159
+ const argv =
160
+ kind === 'new'
161
+ ? ['git', 'diff', '--no-color', '--no-ext-diff', '--no-index', '--', '/dev/null', rel]
162
+ : ['git', 'diff', '--no-color', '--no-ext-diff', ref, '--', rel];
163
+ const res = await exec(argv, { cwd: repoRoot, env, timeoutMs: GIT_TIMEOUT_MS, ...(kind === 'new' ? { okExitCodes: [0, 1] } : {}) });
164
+ if (res.result !== 'ok') throw new PacketError('git', `git diff failed for ${rel} (exit ${res.code})`);
165
+ const content = onDisk ? readRepoText(repoRoot, rel) : null;
166
+ return { file: rel, kind, diffText: res.stdout, content, ...parseDiff(res.stdout) };
167
+ }
168
+
169
+ /**
170
+ * @typedef {object} Packet
171
+ * @property {'ok'} status
172
+ * @property {string} text
173
+ * @property {string[]} hunkHeaders - what `reviewed_hunks` must equal, in order.
174
+ * @property {'whole' | 'hunks' | 'minimal' | 'recheck'} contextMode
175
+ * @property {number} contextLines
176
+ * @property {number} digestLines - digest lines kept.
177
+ * @property {number} tokensIn - estimated.
178
+ * @property {number} budget
179
+ * @property {boolean} overBudget - still over after every shrink step (facts too large).
180
+ */
181
+
182
+ /**
183
+ * Assemble a reviewer packet (pure).
184
+ * @param {{
185
+ * diff: FileDiff, lens: string, rulesDigest?: string, factsExcerpt?: string,
186
+ * cfg?: Record<string, any>, contextMode?: 'auto' | 'recheck', budget?: number,
187
+ * }} opts
188
+ * @returns {Packet | {status: 'split_required', tokensIn: number, budget: number}}
189
+ */
190
+ export function assemblePacket({ diff, lens, rulesDigest = '', factsExcerpt = '', cfg, contextMode = 'auto', budget }) {
191
+ const lensText = loadLens(lens);
192
+ const limit = budget ?? budgetFor(cfg, lens === 'quick' ? 'quick_in' : 'full_in');
193
+ const hunkHeaders = diff.hunks.map((h) => h.header);
194
+ const diffSection = [`file: ${diff.file}`, 'hunks:', ...hunkHeaders.map((h) => `- ${h}`), '', diff.diffText.trimEnd()].join('\n');
195
+ const bare = render(lensText, [], '', '', diffSection);
196
+ if (estimateTokens(bare) > limit) return { status: 'split_required', tokensIn: estimateTokens(bare), budget: limit };
197
+
198
+ let digest = splitLines(rulesDigest).slice(0, DIGEST_MAX_LINES);
199
+ let ctx = buildContext({ file: diff.file, content: diff.content, hunks: diff.hunks, cfg, mode: contextMode });
200
+ let text = render(lensText, digest, factsExcerpt, ctx.text, diffSection);
201
+ if (estimateTokens(text) > limit) {
202
+ ctx = buildContext({ file: diff.file, content: diff.content, hunks: diff.hunks, cfg, mode: 'minimal' });
203
+ text = render(lensText, digest, factsExcerpt, ctx.text, diffSection);
204
+ }
205
+ while (estimateTokens(text) > limit && digest.length > 0) {
206
+ digest = digest.slice(0, -1);
207
+ text = render(lensText, digest, factsExcerpt, ctx.text, diffSection);
208
+ }
209
+ const tokensIn = estimateTokens(text);
210
+ return { status: 'ok', text, hunkHeaders, contextMode: ctx.mode, contextLines: ctx.lineCount, digestLines: digest.length, tokensIn, budget: limit, overBudget: tokensIn > limit };
211
+ }
212
+
213
+ /**
214
+ * @param {string} lensText @param {string[]} digest @param {string} facts @param {string} context
215
+ * @param {string} diffSection
216
+ */
217
+ function render(lensText, digest, facts, context, diffSection) {
218
+ return [
219
+ '# code-forge review packet',
220
+ '## lens',
221
+ lensText,
222
+ '## project rules',
223
+ digest.length > 0 ? digest.join('\n') : '(none)',
224
+ '## facts',
225
+ facts.trim().length > 0 ? facts.trimEnd() : '(none)',
226
+ '## context',
227
+ context.length > 0 ? context : '(none)',
228
+ '## diff',
229
+ diffSection,
230
+ '',
231
+ ].join('\n');
232
+ }
233
+
234
+ /**
235
+ * Read one file and assemble its packet (the §4.1 entry point). A tracked file with no change
236
+ * returns `{status: 'no_change'}` — nothing to review.
237
+ * @param {{repoRoot: string, file: string, base?: string | null, lens: string, rulesDigest?: string, factsExcerpt?: string, cfg?: Record<string, any>}} opts
238
+ * @returns {Promise<(Packet & {diff: FileDiff}) | {status: 'no_change', file: string} | {status: 'split_required', tokensIn: number, budget: number}>}
239
+ */
240
+ export async function buildPacket(opts) {
241
+ const diff = await readFileDiff(opts);
242
+ if (diff.diffText.trim().length === 0) return { status: 'no_change', file: diff.file };
243
+ const packet = assemblePacket({ ...opts, diff });
244
+ return packet.status === 'ok' ? { ...packet, diff } : packet;
245
+ }
246
+
247
+ /**
248
+ * The judge's packet (§4.2 step 3, §4.4): the judge lens, the hunk list, the two blind reports,
249
+ * and the diff only when `review.judge_sees_diff` is true (R3, default false).
250
+ * @param {{diff: FileDiff, reports: {A: unknown, B: unknown}, cfg?: Record<string, any>}} opts
251
+ * @returns {{status: 'ok', text: string, hunkHeaders: string[], tokensIn: number, budget: number, contextMode: null}}
252
+ */
253
+ export function assembleJudgePacket({ diff, reports, cfg }) {
254
+ const hunkHeaders = diff.hunks.map((h) => h.header);
255
+ const seesDiff = cfg?.review?.judge_sees_diff === true;
256
+ const text = [
257
+ '# code-forge review packet',
258
+ '## lens',
259
+ loadLens('judge'),
260
+ '## reviewer A',
261
+ JSON.stringify(reports.A, null, 1),
262
+ '## reviewer B',
263
+ JSON.stringify(reports.B, null, 1),
264
+ '## diff',
265
+ [`file: ${diff.file}`, 'hunks:', ...hunkHeaders.map((h) => `- ${h}`), ...(seesDiff ? ['', diff.diffText.trimEnd()] : [])].join('\n'),
266
+ '',
267
+ ].join('\n');
268
+ return { status: 'ok', text, hunkHeaders, tokensIn: estimateTokens(text), budget: budgetFor(cfg, 'judge_in'), contextMode: null };
269
+ }
270
+
271
+ /**
272
+ * A repo file's text, read through `readRegularFileNoFollow` (O_NOFOLLOW + fstat on the open fd:
273
+ * a file swapped for a symlink after the lstat checks is refused, never followed).
274
+ * @param {string} repoRoot @param {string} rel @returns {string | null} null when missing.
275
+ * @throws {PacketError} `bad-path`
276
+ */
277
+ function readRepoText(repoRoot, rel) {
278
+ try {
279
+ return readRegularFileNoFollow(repoRoot, rel)?.toString('utf8') ?? null;
280
+ } catch (err) {
281
+ if (err instanceof WorkerError) throw new PacketError('bad-path', err.message);
282
+ throw err;
283
+ }
284
+ }
285
+
286
+ /**
287
+ * Why `rel` may not be attached, or null.
288
+ * @param {string} repoRoot @param {string} rel @returns {Promise<string | null>}
289
+ */
290
+ async function attachRefusal(repoRoot, rel) {
291
+ if (isSecretLike(rel)) return 'refused: secret-like path';
292
+ let st;
293
+ try {
294
+ st = lstatSync(path.join(repoRoot, rel));
295
+ } catch {
296
+ return 'missing';
297
+ }
298
+ if (!st.isFile()) return 'not a file';
299
+ const res = await exec(['git', 'ls-files', '-z', '--error-unmatch', '--', rel], { cwd: repoRoot, env: reviewGitEnv(), timeoutMs: GIT_TIMEOUT_MS });
300
+ if (res.result !== 'ok' || res.stdout.length === 0) return 'refused: not tracked by git';
301
+ return null;
302
+ }
303
+
304
+ /**
305
+ * The `needs_file` follow-up (§4.2): the requested files, read-only, appended AFTER the packet
306
+ * (the stable prefix stays stable). Only regular files git TRACKS are attached — an untracked or
307
+ * ignored file (`.env`, a key) never reaches a provider — and secret-like paths are refused even
308
+ * when tracked; every refusal is named in the section. The attachment is trimmed to `budgetTokens`.
309
+ * @param {{repoRoot: string, paths: ReadonlyArray<unknown>, budgetTokens: number}} opts
310
+ * @returns {Promise<string>} the section to append.
311
+ */
312
+ export async function attachFiles({ repoRoot, paths, budgetTokens }) {
313
+ const out = ['## attached files (read-only, requested in needs_file)'];
314
+ let left = budgetTokens * 4;
315
+ for (const raw of paths.slice(0, 10)) {
316
+ let rel;
317
+ try {
318
+ rel = assertPacketPath(repoRoot, raw);
319
+ } catch (err) {
320
+ // a directory passed the string checks (so `raw` is a plain repo-relative path) and is named
321
+ const notFile = err instanceof PacketError && err.message === 'not a regular file';
322
+ out.push(notFile ? `### ${String(raw)} (not a file)` : '### (refused: not a repo-relative path)');
323
+ continue;
324
+ }
325
+ const refusal = await attachRefusal(repoRoot, rel);
326
+ if (refusal) {
327
+ out.push(`### ${rel} (${refusal})`);
328
+ continue;
329
+ }
330
+ let text;
331
+ try {
332
+ text = readRepoText(repoRoot, rel);
333
+ } catch (err) {
334
+ if (!(err instanceof PacketError)) throw err;
335
+ out.push(`### ${rel} (refused: ${err.message})`);
336
+ continue;
337
+ }
338
+ if (text === null) {
339
+ out.push(`### ${rel} (missing)`);
340
+ continue;
341
+ }
342
+ const lines = splitLines(text);
343
+ out.push(`### ${rel}`);
344
+ for (let i = 0; i < lines.length; i += 1) {
345
+ const line = `${i + 1}| ${lines[i]}`;
346
+ left -= Buffer.byteLength(line) + 1;
347
+ if (left < 0) {
348
+ out.push('(trimmed: over budget)');
349
+ return `${out.join('\n')}\n`;
350
+ }
351
+ out.push(line);
352
+ }
353
+ }
354
+ return `${out.join('\n')}\n`;
355
+ }
@@ -0,0 +1,149 @@
1
+ /**
2
+ * Finding triage (plan §4.2 step 4, §3.3, O11; block B12b).
3
+ *
4
+ * `triageFindings` sorts the findings of ONE file's review into `fix_now` and `nit`:
5
+ * - findings that come FROM A JUDGE (dual adaptive or consensus) are FINAL: `critical` and
6
+ * `warning` are `fix_now`, `nit` is `nit`. S1 `defect` still runs on each of them (when Jev is
7
+ * reachable and the question is not disabled) but the answer is logged `source: shadow` and
8
+ * NEVER changes the verdict;
9
+ * - findings from a SINGLE L2 session go to S1 `defect`: the raw `noul` probability is banded by
10
+ * `bandFinding` (`fix_now` ≥ 0.90 · `judge` 0.40–0.90 · `nit` < 0.40). The mid band is batched
11
+ * to ONE L3 ruling per file (`deps.rule`). Jev unavailable or `defect` disabled ⇒ §3.5 fallback:
12
+ * `defect` has no rule (`alwaysResidue`), so every finding is residue for the L3 ruling;
13
+ * - fail closed: a mid-band finding with no ruling available (no `deps.rule`, or it failed or
14
+ * left the id out) is `fix_now` unless its severity is `nit`.
15
+ * One `review.triage` row per finding: `{file, finding, severity, source, p, band, verdict}`.
16
+ */
17
+
18
+ import { buildQuestionPayload, isQuestionDisabled } from '../decide/questions.mjs';
19
+ import { bandFinding, bandResolved } from '../decide/thresholds.mjs';
20
+ import { alwaysResidue } from '../decide/fallback-rules.mjs';
21
+
22
+ /**
23
+ * @typedef {{id: string, file?: string, line_start: number, line_end: number, severity: 'critical' | 'warning' | 'nit', category?: string, claim?: string, evidence?: string, fix?: string}} Finding
24
+ * @typedef {(req: {state: Record<string, any>, questions: Record<string, any>}) => Promise<Record<string, any> | null>} JevAsk
25
+ * an `askJev`-shaped call with the key already bound (`{ok, answers}` or `{ok: false, kind}`).
26
+ * @typedef {(req: {file: string, findings: Finding[]}) => Promise<Record<string, 'fix_now' | 'nit'> | null>} RuleFn
27
+ * one L3 ruling for a file's mid-band findings: id ⇒ verdict.
28
+ * @typedef {(row: Record<string, any>) => Promise<unknown>} WriteRow
29
+ */
30
+
31
+ /** @param {WriteRow | undefined} writeRow @param {Record<string, any>} row */
32
+ async function note(writeRow, row) {
33
+ try {
34
+ await writeRow?.(row);
35
+ } catch {
36
+ // triage stands; the ledger row is best-effort
37
+ }
38
+ }
39
+
40
+ /** @param {Finding} f @returns {'fix_now' | 'nit'} the verdict a judge's severity means. */
41
+ export const severityVerdict = (f) => (f.severity === 'nit' ? 'nit' : 'fix_now');
42
+
43
+ /**
44
+ * Ask S1 one `noul` question; null when Jev is unavailable, the question is disabled, or the
45
+ * answer is not a probability.
46
+ * @param {'defect' | 'resolved'} id @param {Record<string, any>} state
47
+ * @param {{jev?: JevAsk, cfg?: Record<string, any>}} deps
48
+ * @returns {Promise<number | null>}
49
+ */
50
+ export async function askNoul(id, state, { jev, cfg = {} }) {
51
+ if (typeof jev !== 'function' || isQuestionDisabled(id, cfg)) return null;
52
+ let res;
53
+ try {
54
+ res = await jev({ state, questions: buildQuestionPayload([id], cfg) });
55
+ } catch {
56
+ return null;
57
+ }
58
+ const p = res?.ok === true ? res.answers?.[id]?.noul : undefined;
59
+ return typeof p === 'number' && Number.isFinite(p) && p >= 0 && p <= 1 ? p : null;
60
+ }
61
+
62
+ /**
63
+ * S1 `resolved` for one open finding against the fix hunk: `closed` at ≥ `thresholds.findings.resolved`
64
+ * (0.90), else `recheck`; null when S1 gave no answer (the recheck session decides).
65
+ * @param {{finding: Finding, fixHunk: string, jev?: JevAsk, cfg?: Record<string, any>}} opts
66
+ * @returns {Promise<{p: number | null, band: 'closed' | 'recheck'}>}
67
+ */
68
+ export async function resolvedBand({ finding, fixHunk, jev, cfg = {} }) {
69
+ const p = await askNoul('resolved', { finding, fix_hunk: fixHunk }, { jev, cfg });
70
+ return { p, band: p === null ? 'recheck' : bandResolved(p, cfg) };
71
+ }
72
+
73
+ /**
74
+ * @param {{
75
+ * file: string, findings: Finding[], fromJudge: boolean, diffText?: string,
76
+ * cfg?: Record<string, any>, jev?: JevAsk, rule?: RuleFn, writeRow?: WriteRow,
77
+ * }} opts
78
+ * @returns {Promise<{fix_now: Finding[], nit: Finding[], rulings: number}>}
79
+ */
80
+ export async function triageFindings({ file, findings, fromJudge, diffText = '', cfg = {}, jev, rule, writeRow }) {
81
+ /** @type {Finding[]} */
82
+ const fixNow = [];
83
+ /** @type {Finding[]} */
84
+ const nits = [];
85
+ /** @type {Array<{finding: Finding, p: number | null}>} */
86
+ const mid = [];
87
+ const put = (/** @type {Finding} */ f, /** @type {'fix_now' | 'nit'} */ verdict) => (verdict === 'fix_now' ? fixNow : nits).push(f);
88
+
89
+ for (const finding of findings) {
90
+ const p = await askNoul('defect', { file_diff_hunk: diffText, finding }, { jev, cfg });
91
+ if (fromJudge) {
92
+ const verdict = severityVerdict(finding);
93
+ put(finding, verdict);
94
+ await note(writeRow, { event: 'review.triage', file, finding: finding.id, severity: finding.severity, source: 'shadow', p, band: p === null ? null : bandFinding(p, cfg), verdict });
95
+ continue;
96
+ }
97
+ if (p === null) {
98
+ // §3.5: `defect` has no rule in fallback mode — always residue for the L3 ruling
99
+ mid.push({ finding, p: alwaysResidue('defect').value });
100
+ continue;
101
+ }
102
+ const band = bandFinding(p, cfg);
103
+ if (band === 'judge') {
104
+ mid.push({ finding, p });
105
+ continue;
106
+ }
107
+ put(finding, band);
108
+ await note(writeRow, { event: 'review.triage', file, finding: finding.id, severity: finding.severity, source: 'jev', p, band, verdict: band });
109
+ }
110
+
111
+ let rulings = 0;
112
+ if (mid.length > 0) {
113
+ /** @type {Record<string, 'fix_now' | 'nit'> | null} */
114
+ let ruled = null;
115
+ if (typeof rule === 'function') {
116
+ rulings = 1;
117
+ try {
118
+ ruled = await rule({ file, findings: mid.map((m) => m.finding) });
119
+ } catch {
120
+ ruled = null;
121
+ }
122
+ }
123
+ for (const { finding, p } of mid) {
124
+ const given = ruled?.[finding.id];
125
+ const verdict = given === 'fix_now' || given === 'nit' ? given : severityVerdict(finding);
126
+ put(finding, verdict);
127
+ await note(writeRow, {
128
+ event: 'review.triage',
129
+ file,
130
+ finding: finding.id,
131
+ severity: finding.severity,
132
+ source: p === null ? 's2-fallback' : 's2',
133
+ p,
134
+ band: p === null ? null : 'judge',
135
+ verdict,
136
+ });
137
+ }
138
+ }
139
+ return { fix_now: fixNow, nit: nits, rulings };
140
+ }
141
+
142
+ /**
143
+ * Whether an engine result's final findings came from a judge: dual adaptive (`A`+`B`+`judge`)
144
+ * and consensus both end in a judge session.
145
+ * @param {{sessions?: Array<Record<string, any>>}} result @returns {boolean}
146
+ */
147
+ export function cameFromJudge(result) {
148
+ return Array.isArray(result?.sessions) && result.sessions.some((s) => s?.role === 'judge');
149
+ }
@@ -0,0 +1,70 @@
1
+ /**
2
+ * The stub guard (plan §4.2, O12; block B12a).
3
+ *
4
+ * A session's answer counts as a review only when ALL of these hold:
5
+ * 1. the process exited 0 (not killed at the session timeout);
6
+ * 2. the answer is a JSON object that validates against `finding.schema.json`;
7
+ * 3. `reviewed_hunks` equals the packet's hunk headers EXACTLY and IN ORDER;
8
+ * 4. `tokens_out ≥ review.min_tokens_out` (120).
9
+ * Anything else is `unavailable` with one reason — `timeout`, `exit`, `schema`,
10
+ * `hunks_mismatch`, `too_short` — and is never approval. There is no "approve on missing output":
11
+ * no answer is a `schema` failure, no session is an `exit` failure.
12
+ */
13
+
14
+ import { readFileSync } from 'node:fs';
15
+ import { Ajv2020 } from 'ajv/dist/2020.js';
16
+
17
+ export const UNAVAILABLE_REASONS = Object.freeze(['exit', 'schema', 'hunks_mismatch', 'too_short', 'timeout']);
18
+
19
+ export const DEFAULT_MIN_TOKENS_OUT = 120;
20
+
21
+ /** The source answer schema (compiled per provider by the spawner). */
22
+ export const FINDING_SCHEMA = Object.freeze(JSON.parse(readFileSync(new URL('./finding.schema.json', import.meta.url), 'utf8')));
23
+
24
+ const validateSchema = (() => {
25
+ const body = structuredClone(FINDING_SCHEMA);
26
+ delete body.$id;
27
+ return new Ajv2020({ allErrors: true, strict: false }).compile(body);
28
+ })();
29
+
30
+ /** @param {Record<string, any> | undefined} cfg @returns {number} */
31
+ export function minTokensOut(cfg) {
32
+ const v = cfg?.review?.min_tokens_out;
33
+ return Number.isInteger(v) && v > 0 ? v : DEFAULT_MIN_TOKENS_OUT;
34
+ }
35
+
36
+ /**
37
+ * @typedef {object} Verdict - `ok: true` carries `review` and `tokens_out`; `ok: false` carries
38
+ * `reason` (one of `UNAVAILABLE_REASONS`) and `detail`.
39
+ * @property {boolean} ok
40
+ * @property {Record<string, any>} [review]
41
+ * @property {number} [tokens_out]
42
+ * @property {'exit' | 'schema' | 'hunks_mismatch' | 'too_short' | 'timeout'} [reason]
43
+ * @property {string} [detail]
44
+ */
45
+
46
+ /**
47
+ * @param {Record<string, any> | null | undefined} session - a `spawnSession` result.
48
+ * @param {{hunkHeaders: ReadonlyArray<string>, minTokensOut?: number}} expect
49
+ * @returns {Verdict}
50
+ */
51
+ export function validateReview(session, { hunkHeaders, minTokensOut: min = DEFAULT_MIN_TOKENS_OUT }) {
52
+ if (!session || typeof session !== 'object') return { ok: false, reason: 'exit', detail: 'no session result' };
53
+ if (session.status === 'timeout') return { ok: false, reason: 'timeout', detail: 'killed at the session timeout' };
54
+ const exitCode = session.exit_code;
55
+ if (session.status === 'failed' || session.status === 'unavailable' || (exitCode !== undefined && exitCode !== 0)) {
56
+ return { ok: false, reason: 'exit', detail: `session ${session.status}${exitCode === undefined ? '' : ` (exit ${exitCode})`}` };
57
+ }
58
+ /** @type {any} */
59
+ const answer = session.answer;
60
+ if (session.status !== 'ok' || answer === null || typeof answer !== 'object' || Array.isArray(answer) || validateSchema(answer) !== true) {
61
+ return { ok: false, reason: 'schema', detail: 'the answer does not validate against the finding schema' };
62
+ }
63
+ const got = /** @type {string[]} */ (session.answer.reviewed_hunks);
64
+ if (got.length !== hunkHeaders.length || got.some((h, i) => h !== hunkHeaders[i])) {
65
+ return { ok: false, reason: 'hunks_mismatch', detail: `reviewed_hunks has ${got.length} entries; the packet has ${hunkHeaders.length} hunks` };
66
+ }
67
+ const tokensOut = Number(session.usage?.tokens_out ?? session.row?.tokens_out ?? 0);
68
+ if (!(tokensOut >= min)) return { ok: false, reason: 'too_short', detail: `tokens_out ${tokensOut} < ${min}` };
69
+ return { ok: true, review: session.answer, tokens_out: tokensOut };
70
+ }