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