@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 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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@codedology/code-forge",
3
- "version": "0.3.0",
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.0"
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
+ }
@@ -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
+ }
@@ -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
  }
@@ -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
- const text = typeof parsed?.result === 'string' ? parsed.result : stdout;
181
- const answer = parsed?.structured_output ?? tryJSON(text);
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