@codedology/code-forge 0.3.1 → 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 +10 -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/review/engine.mjs +8 -7
- package/src/review/validate-review.mjs +15 -3
package/CHANGELOG.md
CHANGED
|
@@ -6,6 +6,16 @@ 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
|
+
|
|
9
19
|
## [0.3.1] — 2026-10-01
|
|
10
20
|
|
|
11
21
|
### Fixed
|
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
|
+
}
|
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
|
}
|