@codedology/code-forge 0.3.0 → 0.3.2
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 +20 -0
- package/package.json +1 -1
- package/skill/scripts/forge +1 -1
- package/src/decide/jev-client.mjs +1 -1
- package/src/decide/questions.mjs +16 -3
- package/src/engines/builders/claude.mjs +14 -1
- package/src/review/engine.mjs +8 -7
- package/src/review/validate-review.mjs +15 -3
- package/src/session/spawn.mjs +3 -2
package/CHANGELOG.md
CHANGED
|
@@ -6,6 +6,26 @@ waits on a second real consumer (plan §10.4, Q17).
|
|
|
6
6
|
|
|
7
7
|
## [Unreleased]
|
|
8
8
|
|
|
9
|
+
## [0.3.2] — 2026-10-01
|
|
10
|
+
|
|
11
|
+
### Fixed
|
|
12
|
+
|
|
13
|
+
- Jev: `score` questions (such as `risk`) send their criteria as a list indexed by score. Jev refused the keyed object
|
|
14
|
+
with 422, so `doctor`'s Jev check failed and every `risk` call silently fell back to rules.
|
|
15
|
+
- Review: on a small diff (20 added lines or fewer), a clean pass (passed, no findings, every hunk acknowledged) needs
|
|
16
|
+
only 40 output tokens instead of `review.min_tokens_out`, so a correct short review of a tiny file is no longer
|
|
17
|
+
refused as `too_short`. Any finding, or a larger diff, keeps the full floor.
|
|
18
|
+
|
|
19
|
+
## [0.3.1] — 2026-10-01
|
|
20
|
+
|
|
21
|
+
### Fixed
|
|
22
|
+
|
|
23
|
+
- Claude sessions: the `--json-schema` passed to the Claude CLI no longer carries the top-level `$schema` pointer, which
|
|
24
|
+
the CLI rejected ("no schema with key or ref"), so every Claude reviewer session failed at once and each review came
|
|
25
|
+
back unavailable. Validation still uses the full schema.
|
|
26
|
+
- Grok sessions: the answer is read from Grok's `structuredOutput`/`text` envelope, so Grok reviewer, author and System
|
|
27
|
+
2 calls no longer fail schema validation (`invalid-output`).
|
|
28
|
+
|
|
9
29
|
## [0.3.0] — 2026-10-01
|
|
10
30
|
|
|
11
31
|
### Added
|
package/package.json
CHANGED
package/skill/scripts/forge
CHANGED
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
# copied install with no package next to it); `test/skill/shim.test.mjs` asserts the pin
|
|
11
11
|
# equals package.json's version.
|
|
12
12
|
# The shim holds no logic of its own: every verb, flag and message is the CLI's.
|
|
13
|
-
PINNED_VERSION="0.3.
|
|
13
|
+
PINNED_VERSION="0.3.2"
|
|
14
14
|
set -eu
|
|
15
15
|
|
|
16
16
|
self="$0"
|
|
@@ -91,7 +91,7 @@ async function requestIdOf(response) {
|
|
|
91
91
|
* @typedef {object} JevQuestionSpec
|
|
92
92
|
* @property {'choice'|'score'|'noul'} type
|
|
93
93
|
* @property {string} instructions
|
|
94
|
-
* @property {Record<string, string>} criteria
|
|
94
|
+
* @property {Record<string, string> | string[]} criteria - a list (index = score) for `score`
|
|
95
95
|
*
|
|
96
96
|
* @typedef {object} JevSuccess
|
|
97
97
|
* @property {true} ok
|
package/src/decide/questions.mjs
CHANGED
|
@@ -146,16 +146,29 @@ export function renderQuestion(id, cfg = {}) {
|
|
|
146
146
|
* `askJev`'s `questions` argument takes.
|
|
147
147
|
* @param {string[]} ids
|
|
148
148
|
* @param {Record<string, any>} [cfg]
|
|
149
|
-
* @returns {Record<string, {type: 'choice'|'score'|'noul', instructions: string, criteria: Record<string, string>}>}
|
|
149
|
+
* @returns {Record<string, {type: 'choice'|'score'|'noul', instructions: string, criteria: Record<string, string> | string[]}>}
|
|
150
150
|
*/
|
|
151
151
|
export function buildQuestionPayload(ids, cfg = {}) {
|
|
152
152
|
if (!Array.isArray(ids) || ids.length === 0) {
|
|
153
153
|
throw new TypeError('buildQuestionPayload: ids must be a non-empty array');
|
|
154
154
|
}
|
|
155
|
-
/** @type {Record<string, {type: 'choice'|'score'|'noul', instructions: string, criteria: Record<string, string>}>} */
|
|
155
|
+
/** @type {Record<string, {type: 'choice'|'score'|'noul', instructions: string, criteria: Record<string, string> | string[]}>} */
|
|
156
156
|
const out = {};
|
|
157
157
|
for (const id of ids) {
|
|
158
|
-
out[id] = renderQuestion(id, cfg);
|
|
158
|
+
out[id] = toWire(renderQuestion(id, cfg));
|
|
159
159
|
}
|
|
160
160
|
return out;
|
|
161
161
|
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Jev's wire shape: a `score` question takes `criteria` as a LIST whose index is the score (an
|
|
165
|
+
* object keyed `0..n` is refused with 422 "Input should be a valid list"); `choice` and `noul`
|
|
166
|
+
* keep the keyed object. The answer's `legend`/`probabilities` come back keyed `"0".."n"`.
|
|
167
|
+
* @param {{type: 'choice'|'score'|'noul', instructions: string, criteria: Record<string, string>}} q
|
|
168
|
+
* @returns {{type: 'choice'|'score'|'noul', instructions: string, criteria: Record<string, string> | string[]}}
|
|
169
|
+
*/
|
|
170
|
+
function toWire(q) {
|
|
171
|
+
if (q.type !== 'score') return q;
|
|
172
|
+
const keys = Object.keys(q.criteria).sort((a, b) => Number(a) - Number(b));
|
|
173
|
+
return { ...q, criteria: keys.map((k) => q.criteria[k]) };
|
|
174
|
+
}
|
|
@@ -202,7 +202,7 @@ function buildClosedBookArgv(params) {
|
|
|
202
202
|
argv.push('--no-session-persistence');
|
|
203
203
|
if (systemPromptText !== undefined) argv.push('--system-prompt', systemPromptText);
|
|
204
204
|
argv.push('--output-format', 'json');
|
|
205
|
-
if (schema !== undefined) argv.push('--json-schema', JSON.stringify(schema));
|
|
205
|
+
if (schema !== undefined) argv.push('--json-schema', JSON.stringify(stripSchemaMeta(schema)));
|
|
206
206
|
if (maxBudgetUsd !== undefined) argv.push('--max-budget-usd', String(maxBudgetUsd));
|
|
207
207
|
argv.push('--permission-mode', 'dontAsk');
|
|
208
208
|
if (!isFacts) {
|
|
@@ -222,3 +222,16 @@ export function buildClaudeArgv(params) {
|
|
|
222
222
|
assertBaseParams('buildClaudeArgv', params);
|
|
223
223
|
return params.role === 'coder' ? buildCoderArgv(params) : buildClosedBookArgv(params);
|
|
224
224
|
}
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* The Claude CLI's `--json-schema` validator does not know the draft 2020-12 meta-schema and exits
|
|
228
|
+
* 1 on a top-level `$schema` naming it ("no schema with key or ref …"). The local Ajv2020 check
|
|
229
|
+
* keeps the full schema; only the copy handed to the CLI drops the meta-schema pointer.
|
|
230
|
+
* @param {unknown} schema
|
|
231
|
+
* @returns {unknown}
|
|
232
|
+
*/
|
|
233
|
+
function stripSchemaMeta(schema) {
|
|
234
|
+
if (schema === null || typeof schema !== 'object' || Array.isArray(schema) || !('$schema' in schema)) return schema;
|
|
235
|
+
const { $schema: _meta, ...rest } = /** @type {Record<string, unknown>} */ (schema);
|
|
236
|
+
return rest;
|
|
237
|
+
}
|
package/src/review/engine.mjs
CHANGED
|
@@ -28,7 +28,7 @@ import { resolveLevel } from '../config/known-ids.mjs';
|
|
|
28
28
|
import { fallbackRisk } from '../decide/fallback-rules.mjs';
|
|
29
29
|
import { tierFor } from '../proof/tiers.mjs';
|
|
30
30
|
import { assembleJudgePacket, assemblePacket, attachFiles, budgetFor, readFileDiff } from './packet.mjs';
|
|
31
|
-
import { FINDING_SCHEMA, minTokensOut, validateReview } from './validate-review.mjs';
|
|
31
|
+
import { FINDING_SCHEMA, minTokensOut, SMALL_DIFF_ADDED_LINES, validateReview } from './validate-review.mjs';
|
|
32
32
|
import { assertRowPath } from '../worker/ticket.mjs';
|
|
33
33
|
|
|
34
34
|
/**
|
|
@@ -167,11 +167,12 @@ export async function reviewFile(input, deps) {
|
|
|
167
167
|
if (diff.diffText.trim().length === 0) return { status: 'no_change', approved: false, ...head, sessions: [] };
|
|
168
168
|
mkdirSync(workDir, { recursive: true, mode: 0o700 });
|
|
169
169
|
const min = minTokensOut(cfg);
|
|
170
|
+
const smallDiff = diff.plusCount <= SMALL_DIFF_ADDED_LINES;
|
|
170
171
|
|
|
171
172
|
/** @param {SessionSpec} spec @param {{text: string, hunkHeaders: string[], tokensIn: number, contextMode: string | null}} packet */
|
|
172
173
|
const run = async (spec, packet) => {
|
|
173
174
|
/** @type {Record<string, any>} */
|
|
174
|
-
let summary = await runOne(spec, packet.text, packet, { deps, workDir, min, cfg });
|
|
175
|
+
let summary = await runOne(spec, packet.text, packet, { deps, workDir, min, smallDiff, cfg });
|
|
175
176
|
const needs = summary.review?.needs_file;
|
|
176
177
|
if (spec.role === 'reviewer' && summary.status === 'ok' && Array.isArray(needs) && needs.length > 0) {
|
|
177
178
|
const paths = safeNeeds(needs);
|
|
@@ -182,7 +183,7 @@ export async function reviewFile(input, deps) {
|
|
|
182
183
|
extra = null;
|
|
183
184
|
}
|
|
184
185
|
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
|
+
summary = { ...(await runOne(spec, `${packet.text}${extra}`, packet, { deps, workDir, min, smallDiff, cfg })), needs_file_round: true };
|
|
186
187
|
}
|
|
187
188
|
return summary;
|
|
188
189
|
};
|
|
@@ -206,7 +207,7 @@ export async function reviewFile(input, deps) {
|
|
|
206
207
|
if (judgeSpec) {
|
|
207
208
|
const bySlot = Object.fromEntries(done.map((s) => [s.slot, s.review]));
|
|
208
209
|
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
|
+
final = await runOne(judgeSpec, packet.text, packet, { deps, workDir, min, smallDiff, cfg });
|
|
210
211
|
sessions.push(publicSummary(final));
|
|
211
212
|
if (final.status !== 'ok') {
|
|
212
213
|
await note(deps, { event: 'review.unavailable', reason: final.reason, lens: 'judge' });
|
|
@@ -222,9 +223,9 @@ export async function reviewFile(input, deps) {
|
|
|
222
223
|
* One session: packet file (0600, removed after), spawn, stub guard.
|
|
223
224
|
* @param {SessionSpec} spec @param {string} text
|
|
224
225
|
* @param {{hunkHeaders: string[], tokensIn: number, contextMode: string | null}} packet
|
|
225
|
-
* @param {{deps: ReviewDeps, workDir: string, min: number, cfg: Record<string, any>}} env
|
|
226
|
+
* @param {{deps: ReviewDeps, workDir: string, min: number, smallDiff?: boolean, cfg: Record<string, any>}} env
|
|
226
227
|
*/
|
|
227
|
-
async function runOne(spec, text, packet, { deps, workDir, min }) {
|
|
228
|
+
async function runOne(spec, text, packet, { deps, workDir, min, smallDiff = false }) {
|
|
228
229
|
const promptPath = path.join(workDir, `${spec.lens}-${randomBytes(6).toString('hex')}.md`);
|
|
229
230
|
const base = { lens: spec.lens, role: spec.role, level: spec.level, slot: spec.slot, context_mode: packet.contextMode, ctx_tokens_in: packet.tokensIn };
|
|
230
231
|
writeFileSync(promptPath, text, { mode: 0o600 });
|
|
@@ -244,7 +245,7 @@ async function runOne(spec, text, packet, { deps, workDir, min }) {
|
|
|
244
245
|
} finally {
|
|
245
246
|
rmSync(promptPath, { force: true });
|
|
246
247
|
}
|
|
247
|
-
const verdict = validateReview(res, { hunkHeaders: packet.hunkHeaders, minTokensOut: min });
|
|
248
|
+
const verdict = validateReview(res, { hunkHeaders: packet.hunkHeaders, minTokensOut: min, smallDiff });
|
|
248
249
|
const meta = {
|
|
249
250
|
...base,
|
|
250
251
|
provider: res?.provider ?? null,
|
|
@@ -18,6 +18,15 @@ export const UNAVAILABLE_REASONS = Object.freeze(['exit', 'schema', 'hunks_misma
|
|
|
18
18
|
|
|
19
19
|
export const DEFAULT_MIN_TOKENS_OUT = 120;
|
|
20
20
|
|
|
21
|
+
/**
|
|
22
|
+
* A clean pass (`passed: true`, no findings, every hunk acknowledged) on a SMALL diff legitimately
|
|
23
|
+
* needs few words: a 3-line placeholder reviewed by GPT-6 Astra produced 108 tokens and was refused
|
|
24
|
+
* as `too_short`. For such answers the floor drops to this value; any finding keeps the full floor.
|
|
25
|
+
*/
|
|
26
|
+
export const CLEAN_PASS_MIN_TOKENS_OUT = 40;
|
|
27
|
+
/** A diff with at most this many added lines is "small" for the clean-pass floor. */
|
|
28
|
+
export const SMALL_DIFF_ADDED_LINES = 20;
|
|
29
|
+
|
|
21
30
|
/** The source answer schema (compiled per provider by the spawner). */
|
|
22
31
|
export const FINDING_SCHEMA = Object.freeze(JSON.parse(readFileSync(new URL('./finding.schema.json', import.meta.url), 'utf8')));
|
|
23
32
|
|
|
@@ -45,10 +54,10 @@ export function minTokensOut(cfg) {
|
|
|
45
54
|
|
|
46
55
|
/**
|
|
47
56
|
* @param {Record<string, any> | null | undefined} session - a `spawnSession` result.
|
|
48
|
-
* @param {{hunkHeaders: ReadonlyArray<string>, minTokensOut?: number}} expect
|
|
57
|
+
* @param {{hunkHeaders: ReadonlyArray<string>, minTokensOut?: number, smallDiff?: boolean}} expect - `smallDiff` lowers the floor to `CLEAN_PASS_MIN_TOKENS_OUT` for a clean pass only
|
|
49
58
|
* @returns {Verdict}
|
|
50
59
|
*/
|
|
51
|
-
export function validateReview(session, { hunkHeaders, minTokensOut: min = DEFAULT_MIN_TOKENS_OUT }) {
|
|
60
|
+
export function validateReview(session, { hunkHeaders, minTokensOut: min = DEFAULT_MIN_TOKENS_OUT, smallDiff = false }) {
|
|
52
61
|
if (!session || typeof session !== 'object') return { ok: false, reason: 'exit', detail: 'no session result' };
|
|
53
62
|
if (session.status === 'timeout') return { ok: false, reason: 'timeout', detail: 'killed at the session timeout' };
|
|
54
63
|
const exitCode = session.exit_code;
|
|
@@ -65,6 +74,9 @@ export function validateReview(session, { hunkHeaders, minTokensOut: min = DEFAU
|
|
|
65
74
|
return { ok: false, reason: 'hunks_mismatch', detail: `reviewed_hunks has ${got.length} entries; the packet has ${hunkHeaders.length} hunks` };
|
|
66
75
|
}
|
|
67
76
|
const tokensOut = Number(session.usage?.tokens_out ?? session.row?.tokens_out ?? 0);
|
|
68
|
-
|
|
77
|
+
const review = /** @type {{passed: boolean, findings: unknown[]}} */ (session.answer);
|
|
78
|
+
const cleanPass = review.passed === true && Array.isArray(review.findings) && review.findings.length === 0;
|
|
79
|
+
const floor = smallDiff && cleanPass ? Math.min(min, CLEAN_PASS_MIN_TOKENS_OUT) : min;
|
|
80
|
+
if (!(tokensOut >= floor)) return { ok: false, reason: 'too_short', detail: `tokens_out ${tokensOut} < ${floor}` };
|
|
69
81
|
return { ok: true, review: session.answer, tokens_out: tokensOut };
|
|
70
82
|
}
|
package/src/session/spawn.mjs
CHANGED
|
@@ -177,8 +177,9 @@ export function readAnswer(cli, stdout, outText) {
|
|
|
177
177
|
return { parsed: errorEvent ?? null, text, answer: tryJSON(text), usage: parseUsage('codex', usageEvent ?? null) };
|
|
178
178
|
}
|
|
179
179
|
const parsed = tryJSON(stdout.trim());
|
|
180
|
-
|
|
181
|
-
const
|
|
180
|
+
// Claude's envelope carries `result` + `structured_output`; Grok's carries `text` + `structuredOutput`.
|
|
181
|
+
const text = typeof parsed?.result === 'string' ? parsed.result : typeof parsed?.text === 'string' ? parsed.text : stdout;
|
|
182
|
+
const answer = parsed?.structured_output ?? parsed?.structuredOutput ?? tryJSON(text);
|
|
182
183
|
return { parsed: parsed ?? null, text, answer, usage: parseUsage(/** @type {"claude"|"grok"} */ (cli), parsed ?? null) };
|
|
183
184
|
}
|
|
184
185
|
|