@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 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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@codedology/code-forge",
3
- "version": "0.3.1",
3
+ "version": "0.3.2",
4
4
  "description": "Plan/harden/code/review orchestration CLI and Agent Skill for cross-model, parallel, evidence-gated feature delivery.",
5
5
  "repository": {
6
6
  "type": "git",
@@ -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.1"
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
@@ -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
+ }
@@ -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
- if (!(tokensOut >= min)) return { ok: false, reason: 'too_short', detail: `tokens_out ${tokensOut} < ${min}` };
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
  }