@codedology/code-forge 0.7.0 → 0.7.1

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,14 @@ waits on a second real consumer (plan §10.4, Q17).
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ## [0.7.1] — 2026-10-07
10
+
11
+ ### Fixed
12
+
13
+ - A structured clean pass (passed: true, no findings, every hunk acknowledged, a non-blank summary) is no longer refused
14
+ as too_short on a larger diff: it needs 40 output tokens whatever the diff size (clean passes measured 108-121 tokens;
15
+ issue #6). An answer with any finding, or a blank summary, keeps review.min_tokens_out.
16
+
9
17
  ## [0.7.0] — 2026-10-07
10
18
 
11
19
  ### Fixed
package/README.md CHANGED
@@ -8,16 +8,24 @@ for cross-model, parallel, evidence-gated feature delivery — on Claude Code, C
8
8
  > `examples/` have all landed (`docs/reference/blocks.json` lists every build block). What changed
9
9
  > in each version is in [CHANGELOG.md](CHANGELOG.md).
10
10
 
11
- ## What's new in 0.6
11
+ ## What's new in 0.7
12
12
 
13
13
  Next release:
14
14
 
15
+ - **Short clean reviews count.** A clean pass (no findings, every hunk acknowledged, a summary)
16
+ needs only 40 output tokens on any diff size, so a correct short "no defect" review is no
17
+ longer refused as `too_short`.
18
+
19
+ In 0.7.0:
20
+
15
21
  - **The close gate now checks every file.** `block close` could close a block with critical
16
22
  findings still open: files inside an owned folder were skipped, and so was work committed
17
23
  before `block open` or done in another worktree. Now every reviewed file is checked, a newer
18
24
  failed review beats an older approval, a critical needs your waiver, a change outside the
19
25
  block's files is refused, and `--no-require-reviews` asks you at a terminal.
20
26
 
27
+ ## What's new in 0.6
28
+
21
29
  In 0.6.1:
22
30
 
23
31
  - **Never relax a rule alone.** A new skill rule (R17): the orchestrator and the delegate never
@@ -4,7 +4,7 @@
4
4
  "plans/code-forge-plan-v1.3.md §10.4",
5
5
  "plans/NIGHT-LOG.md"
6
6
  ],
7
- "block_count": 55,
7
+ "block_count": 56,
8
8
  "baseline_ids": ["B0", "B1", "B2", "B3", "B4", "B5", "B6", "B7", "B8"],
9
9
  "baseline_note": "Wave 0-1 blocks, landed before v1.3; depends_on may name them, but they have no entry under blocks.",
10
10
  "blocks": {
@@ -748,6 +748,20 @@
748
748
  "B50"
749
749
  ],
750
750
  "note": "root-added 2026-10-07; GitHub issue #2 field note 2026-10-07; also amends src/state/block.mjs, src/review/triage.mjs, src/worker/loop.mjs, skill/SKILL.md, skill/references/{code,review,decisions}.md, skill/references/adapters/{claude-code,solo}.md, docs/how-it-works.md, docs/tutorial-first-test.md, docs/concepts.md"
751
+ },
752
+ "B53": {
753
+ "title": "Clean-pass token floor for any diff size (issue #6): a structured clean pass (passed: true, no findings, hunks acknowledged in order, a non-blank summary) needs min(review.min_tokens_out, 40) output tokens; any finding, passed: false or a blank summary keeps the full floor",
754
+ "wave": 12,
755
+ "level": "L1",
756
+ "status": "landed",
757
+ "owned": [
758
+ "src/review/validate-review.mjs",
759
+ "test/review/validate-review.test.mjs"
760
+ ],
761
+ "depends_on": [
762
+ "B52"
763
+ ],
764
+ "note": "root-added 2026-10-07; GitHub issue #6; also amends src/review/engine.mjs (smallDiff removed), schema/code-forge.schema.json, docs/reference/config.md, docs/how-it-works.md, skill/references/review.md"
751
765
  }
752
766
  }
753
767
  }
@@ -105,7 +105,7 @@ Every other key defaults as shown, or is entirely absent from a minimal file. `a
105
105
  | `review.context.min_context_lines` | integer | | `10` | min 0 |
106
106
  | `review.block_budget_tokens` | integer | | `150000` | min 1 |
107
107
  | `review.block_budget_usd` | number \| null | | `null` | |
108
- | `review.min_tokens_out` | integer | | `120` | min 1 |
108
+ | `review.min_tokens_out` | integer | | `120` | min 1; The stub guard: a review answer with fewer output tokens is unavailable (too_short). A clean pass (passed: true, no findings, a non-blank summary) needs only min(this, 40), whatever the diff size (B53). |
109
109
  | `review.batch_small_files` | boolean | | `true` | |
110
110
  | `review.session_timeout_s` | integer | | `300` | min 1 |
111
111
  | `review.max_rounds_per_file` | integer | | `4` | min 2; max 6 |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@codedology/code-forge",
3
- "version": "0.7.0",
3
+ "version": "0.7.1",
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",
@@ -136,7 +136,7 @@
136
136
  "anyOf": [{ "type": "number", "exclusiveMinimum": 0 }, { "type": "null" }],
137
137
  "default": null
138
138
  },
139
- "min_tokens_out": { "type": "integer", "minimum": 1, "default": 120 },
139
+ "min_tokens_out": { "type": "integer", "minimum": 1, "default": 120, "description": "The stub guard: a review answer with fewer output tokens is unavailable (too_short). A clean pass (passed: true, no findings, a non-blank summary) needs only min(this, 40), whatever the diff size (B53)." },
140
140
  "batch_small_files": { "type": "boolean", "default": true },
141
141
  "session_timeout_s": { "type": "integer", "minimum": 1, "default": 300 },
142
142
  "max_rounds_per_file": { "type": "integer", "minimum": 2, "maximum": 6, "default": 4 },
@@ -19,7 +19,7 @@ Every reviewer, recheck, judge and S2 session runs with cwd = a fresh empty temp
19
19
 
20
20
  ## §4 The stub guard — what counts as a review (R11)
21
21
 
22
- A result is a review only if the process exited 0, the JSON validates against the finding schema, `reviewed_hunks` equals the packet's hunk headers exactly and in order, and the output has at least `review.min_tokens_out` tokens. Anything else is ledger `review.unavailable` (`exit`, `schema`, `hunks_mismatch`, `too_short`, `timeout`, `budget` — the run reached `budget.usd`, `ledger.md` §3.1) and **is not approval**; the worker retries once on the level's fallback, then the file stays unreviewed and the block cannot close. A session that hangs is killed at `review.session_timeout_s` (default 300), process group and all, and is spawned once more on the same packet and level; only a second timeout is `unavailable: timeout`. Each timed-out attempt leaves a `review.session_timeout` ledger note with the last 4 KB of its stderr (redacted, never the packet) and only the byte count and last event types of its stdout.
22
+ A result is a review only if the process exited 0, the JSON validates against the finding schema, `reviewed_hunks` equals the packet's hunk headers exactly and in order, and the output has at least `review.min_tokens_out` tokens (a clean pass — `passed: true`, no findings, a non-blank summary — needs only 40, whatever the diff size). Anything else is ledger `review.unavailable` (`exit`, `schema`, `hunks_mismatch`, `too_short`, `timeout`, `budget` — the run reached `budget.usd`, `ledger.md` §3.1) and **is not approval**; the worker retries once on the level's fallback, then the file stays unreviewed and the block cannot close. A session that hangs is killed at `review.session_timeout_s` (default 300), process group and all, and is spawned once more on the same packet and level; only a second timeout is `unavailable: timeout`. Each timed-out attempt leaves a `review.session_timeout` ledger note with the last 4 KB of its stderr (redacted, never the packet) and only the byte count and last event types of its stdout.
23
23
 
24
24
  ## §5 The convergence rule — bounded fix rounds (R15)
25
25
 
@@ -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.7.0"
13
+ PINNED_VERSION="0.7.1"
14
14
  set -eu
15
15
 
16
16
  self="$0"
@@ -33,7 +33,7 @@ import { blockKind, isDocsKind } from '../decide/escalation.mjs';
33
33
  import { fallbackRisk } from '../decide/fallback-rules.mjs';
34
34
  import { tierFor } from '../proof/tiers.mjs';
35
35
  import { assembleJudgePacket, assemblePacket, attachFiles, budgetFor, readFileDiff } from './packet.mjs';
36
- import { FINDING_SCHEMA, minTokensOut, SMALL_DIFF_ADDED_LINES, validateReview } from './validate-review.mjs';
36
+ import { FINDING_SCHEMA, minTokensOut, validateReview } from './validate-review.mjs';
37
37
  import { spawnWithTimeoutRetry } from './session-retry.mjs';
38
38
  import { assertRowPath } from '../worker/ticket.mjs';
39
39
 
@@ -194,12 +194,11 @@ export async function reviewFile(input, deps) {
194
194
  if (diff.diffText.trim().length === 0) return { status: 'no_change', approved: false, ...head, sessions: [] };
195
195
  mkdirSync(workDir, { recursive: true, mode: 0o700 });
196
196
  const min = minTokensOut(cfg);
197
- const smallDiff = diff.plusCount <= SMALL_DIFF_ADDED_LINES;
198
197
 
199
198
  /** @param {SessionSpec} spec @param {{text: string, hunkHeaders: string[], tokensIn: number, contextMode: string | null}} packet */
200
199
  const run = async (spec, packet) => {
201
200
  /** @type {Record<string, any>} */
202
- let summary = await runOne(spec, packet.text, packet, { deps, workDir, min, smallDiff, cfg });
201
+ let summary = await runOne(spec, packet.text, packet, { deps, workDir, min, cfg });
203
202
  const needs = summary.review?.needs_file;
204
203
  if (spec.role === 'reviewer' && summary.status === 'ok' && Array.isArray(needs) && needs.length > 0) {
205
204
  const paths = safeNeeds(needs);
@@ -210,7 +209,7 @@ export async function reviewFile(input, deps) {
210
209
  extra = null;
211
210
  }
212
211
  if (extra === null) return { ...summary, status: 'unavailable', reason: 'needs_file-refused', review: null };
213
- summary = { ...(await runOne(spec, `${packet.text}${extra}`, packet, { deps, workDir, min, smallDiff, cfg })), needs_file_round: true };
212
+ summary = { ...(await runOne(spec, `${packet.text}${extra}`, packet, { deps, workDir, min, cfg })), needs_file_round: true };
214
213
  }
215
214
  return summary;
216
215
  };
@@ -234,7 +233,7 @@ export async function reviewFile(input, deps) {
234
233
  if (judgeSpec) {
235
234
  const bySlot = Object.fromEntries(done.map((s) => [s.slot, s.review]));
236
235
  const packet = assembleJudgePacket({ diff, reports: { A: bySlot.A, B: bySlot.B }, cfg });
237
- final = await runOne(judgeSpec, packet.text, packet, { deps, workDir, min, smallDiff, cfg });
236
+ final = await runOne(judgeSpec, packet.text, packet, { deps, workDir, min, cfg });
238
237
  sessions.push(publicSummary(final));
239
238
  if (final.status !== 'ok') {
240
239
  await note(deps, { event: 'review.unavailable', reason: final.reason, lens: 'judge' });
@@ -250,9 +249,9 @@ export async function reviewFile(input, deps) {
250
249
  * One session: packet file (0600, removed after), spawn, stub guard.
251
250
  * @param {SessionSpec} spec @param {string} text
252
251
  * @param {{hunkHeaders: string[], tokensIn: number, contextMode: string | null}} packet
253
- * @param {{deps: ReviewDeps, workDir: string, min: number, smallDiff?: boolean, cfg: Record<string, any>}} env
252
+ * @param {{deps: ReviewDeps, workDir: string, min: number, cfg: Record<string, any>}} env
254
253
  */
255
- async function runOne(spec, text, packet, { deps, workDir, min, smallDiff = false }) {
254
+ async function runOne(spec, text, packet, { deps, workDir, min }) {
256
255
  const promptPath = path.join(workDir, `${spec.lens}-${randomBytes(6).toString('hex')}.md`);
257
256
  const base = { lens: spec.lens, role: spec.role, level: spec.level, slot: spec.slot, context_mode: packet.contextMode, ctx_tokens_in: packet.tokensIn };
258
257
  writeFileSync(promptPath, text, { mode: 0o600 });
@@ -285,7 +284,7 @@ async function runOne(spec, text, packet, { deps, workDir, min, smallDiff = fals
285
284
  } finally {
286
285
  rmSync(promptPath, { force: true });
287
286
  }
288
- const verdict = validateReview(res, { hunkHeaders: packet.hunkHeaders, minTokensOut: min, smallDiff });
287
+ const verdict = validateReview(res, { hunkHeaders: packet.hunkHeaders, minTokensOut: min });
289
288
  const meta = {
290
289
  ...base,
291
290
  provider: res?.provider ?? null,
@@ -5,7 +5,7 @@
5
5
  * 1. the process exited 0 (not killed at the session timeout);
6
6
  * 2. the answer is a JSON object that validates against `finding.schema.json`;
7
7
  * 3. `reviewed_hunks` equals the packet's hunk headers EXACTLY and IN ORDER;
8
- * 4. `tokens_out ≥ review.min_tokens_out` (120).
8
+ * 4. `tokens_out ≥ review.min_tokens_out` (120), or ≥ 40 for a structured clean pass (B53).
9
9
  * Anything else is `unavailable` with one reason — `timeout`, `exit`, `schema`,
10
10
  * `hunks_mismatch`, `too_short`, or `budget` (B33: the spawner refused at `budget.usd`) — and is
11
11
  * never approval. There is no "approve on missing output":
@@ -20,13 +20,13 @@ export const UNAVAILABLE_REASONS = Object.freeze(['exit', 'schema', 'hunks_misma
20
20
  export const DEFAULT_MIN_TOKENS_OUT = 120;
21
21
 
22
22
  /**
23
- * A clean pass (`passed: true`, no findings, every hunk acknowledged) on a SMALL diff legitimately
24
- * needs few words: a 3-line placeholder reviewed by GPT-6 Astra produced 108 tokens and was refused
25
- * as `too_short`. For such answers the floor drops to this value; any finding keeps the full floor.
23
+ * A structured clean pass legitimately needs few words, whatever the diff size: GPT-6 Astra clean
24
+ * passes measured 108-121 output tokens while reviews with findings measured 300-1,400, so the full
25
+ * floor refused 11 of 19 clean files on one block (issue #6). A clean pass is `passed: true`, no
26
+ * findings, every hunk acknowledged in order (checked above) and a non-blank `summary`; for it the
27
+ * floor drops to this value. Any finding, or a blank summary, keeps the full floor (B53).
26
28
  */
27
29
  export const CLEAN_PASS_MIN_TOKENS_OUT = 40;
28
- /** A diff with at most this many added lines is "small" for the clean-pass floor. */
29
- export const SMALL_DIFF_ADDED_LINES = 20;
30
30
 
31
31
  /** The source answer schema (compiled per provider by the spawner). */
32
32
  export const FINDING_SCHEMA = Object.freeze(JSON.parse(readFileSync(new URL('./finding.schema.json', import.meta.url), 'utf8')));
@@ -55,10 +55,10 @@ export function minTokensOut(cfg) {
55
55
 
56
56
  /**
57
57
  * @param {Record<string, any> | null | undefined} session - a `spawnSession` result.
58
- * @param {{hunkHeaders: ReadonlyArray<string>, minTokensOut?: number, smallDiff?: boolean}} expect - `smallDiff` lowers the floor to `CLEAN_PASS_MIN_TOKENS_OUT` for a clean pass only
58
+ * @param {{hunkHeaders: ReadonlyArray<string>, minTokensOut?: number}} expect
59
59
  * @returns {Verdict}
60
60
  */
61
- export function validateReview(session, { hunkHeaders, minTokensOut: min = DEFAULT_MIN_TOKENS_OUT, smallDiff = false }) {
61
+ export function validateReview(session, { hunkHeaders, minTokensOut: min = DEFAULT_MIN_TOKENS_OUT }) {
62
62
  if (!session || typeof session !== 'object') return { ok: false, reason: 'exit', detail: 'no session result' };
63
63
  if (session.status === 'timeout') return { ok: false, reason: 'timeout', detail: 'killed at the session timeout' };
64
64
  // B33: the spawner refused the session because the run reached `budget.usd` — nothing ran
@@ -77,9 +77,9 @@ export function validateReview(session, { hunkHeaders, minTokensOut: min = DEFAU
77
77
  return { ok: false, reason: 'hunks_mismatch', detail: `reviewed_hunks has ${got.length} entries; the packet has ${hunkHeaders.length} hunks` };
78
78
  }
79
79
  const tokensOut = Number(session.usage?.tokens_out ?? session.row?.tokens_out ?? 0);
80
- const review = /** @type {{passed: boolean, findings: unknown[]}} */ (session.answer);
81
- const cleanPass = review.passed === true && Array.isArray(review.findings) && review.findings.length === 0;
82
- const floor = smallDiff && cleanPass ? Math.min(min, CLEAN_PASS_MIN_TOKENS_OUT) : min;
80
+ const review = /** @type {{passed: boolean, summary: string, findings: unknown[]}} */ (session.answer);
81
+ const cleanPass = review.passed === true && Array.isArray(review.findings) && review.findings.length === 0 && review.summary.trim().length > 0;
82
+ const floor = cleanPass ? Math.min(min, CLEAN_PASS_MIN_TOKENS_OUT) : min;
83
83
  if (!(tokensOut >= floor)) return { ok: false, reason: 'too_short', detail: `tokens_out ${tokensOut} < ${floor}` };
84
84
  return { ok: true, review: session.answer, tokens_out: tokensOut };
85
85
  }