Skip to content
Merged
8 changes: 7 additions & 1 deletion .claude/commands/calibrate-loop.md
Original file line number Diff line number Diff line change
Expand Up @@ -108,7 +108,13 @@ Proceed to Step 4.

After the Gap Analyzer returns, **you** write the JSON to `$RUN_DIR/gaps.json`.

> **Note**: Discovery evidence from gap analysis is collected programmatically by the orchestrator during Step 4 (Evaluation). Do not manually append to `data/discovery-evidence.json`.
Then collect uncovered actionable gaps into discovery evidence (deterministic CLI — no LLM):

```bash
npx canicode calibrate-collect-gap-evidence $RUN_DIR
```

This reads `gaps.json`, extracts gaps where `actionable: true` and `coveredByRule: null`, and appends them to `data/discovery-evidence.json` as `source: "gap-analysis"` entries.

Append to `$RUN_DIR/activity.jsonl`:
```json
Expand Down
26 changes: 22 additions & 4 deletions src/agents/evidence-collector.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -336,7 +336,7 @@ describe("evidence-collector", () => {
], disPath);

appendDiscoveryEvidence([
{ description: "gap2", category: "color", impact: "moderate", fixture: "fx2", timestamp: "t2", source: "gap-analysis" },
{ description: "gap2", category: "code-quality", impact: "moderate", fixture: "fx2", timestamp: "t2", source: "gap-analysis" },
], disPath);

const raw = JSON.parse(readFileSync(disPath, "utf-8")) as { entries: DiscoveryEvidenceEntry[] };
Expand Down Expand Up @@ -415,7 +415,7 @@ describe("evidence-collector", () => {
]), "utf-8");

appendDiscoveryEvidence([
{ description: "new", category: "color", impact: "easy", fixture: "fx2", timestamp: "t1", source: "gap-analysis" },
{ description: "new", category: "pixel-critical", impact: "easy", fixture: "fx2", timestamp: "t1", source: "gap-analysis" },
], disPath);

const raw = JSON.parse(readFileSync(disPath, "utf-8")) as { schemaVersion: number; entries: DiscoveryEvidenceEntry[] };
Expand All @@ -435,6 +435,24 @@ describe("evidence-collector", () => {
expect(after).toBe(before);
});

it("warns on non-standard category", () => {
const spy = vi.spyOn(console, "warn").mockImplementation(() => {});
appendDiscoveryEvidence([
{ description: "gap1", category: "old-structure", impact: "hard", fixture: "fx1", timestamp: "t1", source: "evaluation" },
], disPath);
expect(spy).toHaveBeenCalledWith(expect.stringContaining('Non-standard category "old-structure"'));
spy.mockRestore();
});

it("does not warn on standard category", () => {
const spy = vi.spyOn(console, "warn").mockImplementation(() => {});
appendDiscoveryEvidence([
{ description: "gap1", category: "pixel-critical", impact: "hard", fixture: "fx1", timestamp: "t1", source: "evaluation" },
], disPath);
expect(spy).not.toHaveBeenCalled();
spy.mockRestore();
});

it("throws when file has unsupported schemaVersion", () => {
const file = { schemaVersion: 999, entries: [] };
writeFileSync(disPath, JSON.stringify(file), "utf-8");
Expand All @@ -454,14 +472,14 @@ describe("evidence-collector", () => {
appendDiscoveryEvidence([
{ description: "gap1", category: "Pixel-critical", impact: "hard", fixture: "fx1", timestamp: "t1", source: "evaluation" },
{ description: "gap2", category: "pixel-critical", impact: "hard", fixture: "fx2", timestamp: "t2", source: "gap-analysis" },
{ description: "gap3", category: "color", impact: "moderate", fixture: "fx1", timestamp: "t1", source: "evaluation" },
{ description: "gap3", category: "token-management", impact: "moderate", fixture: "fx1", timestamp: "t1", source: "evaluation" },
], disPath);

pruneDiscoveryEvidence(["pixel-critical"], disPath);

const raw = JSON.parse(readFileSync(disPath, "utf-8")) as { entries: DiscoveryEvidenceEntry[] };
expect(raw.entries).toHaveLength(1);
expect(raw.entries[0]!.category).toBe("color");
expect(raw.entries[0]!.category).toBe("token-management");
});

it("writes versioned format after prune", () => {
Expand Down
10 changes: 10 additions & 0 deletions src/agents/evidence-collector.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
DiscoveryEvidenceFileSchema,
DISCOVERY_EVIDENCE_SCHEMA_VERSION,
} from "./contracts/evidence.js";
import { CategorySchema } from "../core/contracts/category.js";
import type {
CalibrationEvidenceEntry,
CrossRunEvidence,
Expand Down Expand Up @@ -293,6 +294,15 @@ export function appendDiscoveryEvidence(
evidencePath: string = DEFAULT_DISCOVERY_PATH
): void {
if (entries.length === 0) return;

// Warn on non-standard categories (safety net for converter typos/old labels)
for (const e of entries) {
const parsed = CategorySchema.safeParse(e.category);
if (!parsed.success) {
console.warn(`[evidence] Non-standard category "${e.category}" in discovery evidence (expected: ${CategorySchema.options.join(", ")})`);
}
}

const existing = readDiscoveryEvidence(evidencePath);

// Build map of existing entries keyed by dedupe key
Expand Down
11 changes: 9 additions & 2 deletions src/cli/commands/internal/calibrate-debate.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,13 @@
import { existsSync, readFileSync, writeFileSync } from "node:fs";
import { join, resolve } from "node:path";
import type { CAC } from "cac";
import { z } from "zod";

import { parseDebateResult } from "../../../agents/run-directory.js";
import { loadCalibrationEvidence } from "../../../agents/evidence-collector.js";

const RunDirArgSchema = z.string().trim().min(1, "runDir is required");

// ─── calibrate-gather-evidence ──────────────────────────────────────────────

export interface GatheredEvidence {
Expand Down Expand Up @@ -103,7 +106,9 @@ export function registerGatherEvidence(cli: CAC): void {
"Gather structured evidence for Critic from run artifacts + cross-run data"
)
.action((runDir: string) => {
const dir = resolve(runDir);
const parsed = RunDirArgSchema.safeParse(runDir);
if (!parsed.success) { console.log(`Invalid runDir: ${parsed.error.issues[0]?.message}`); return; }
const dir = resolve(parsed.data);
if (!existsSync(dir)) {
console.log(`Run directory not found: ${runDir}`);
return;
Expand Down Expand Up @@ -134,7 +139,9 @@ export function registerFinalizeDebate(cli: CAC): void {
"Check early-stop or determine stoppingReason after debate"
)
.action((runDir: string) => {
const dir = resolve(runDir);
const parsed = RunDirArgSchema.safeParse(runDir);
if (!parsed.success) { console.log(`Invalid runDir: ${parsed.error.issues[0]?.message}`); return; }
const dir = resolve(parsed.data);
if (!existsSync(dir)) {
console.log(`Run directory not found: ${runDir}`);
return;
Expand Down
13 changes: 10 additions & 3 deletions src/cli/commands/internal/fixture-management.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
import { existsSync } from "node:fs";
import { basename, resolve } from "node:path";
import type { CAC } from "cac";
import { z } from "zod";

const RunDirArgSchema = z.string().trim().min(1, "runDir is required");
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated

import {
listActiveFixtures,
Expand Down Expand Up @@ -121,7 +124,9 @@ export function registerEvidenceEnrich(cli: CAC): void {
"Enrich evidence with Critic's pro/con/confidence from debate.json"
)
.action((runDir: string) => {
const resolvedDir = resolve(runDir);
const parsed = RunDirArgSchema.safeParse(runDir);
if (!parsed.success) { console.log(`Invalid runDir: ${parsed.error.issues[0]?.message}`); return; }
const resolvedDir = resolve(parsed.data);
if (!existsSync(resolvedDir)) {
console.log(`Run directory not found: ${runDir}`);
return;
Expand Down Expand Up @@ -161,11 +166,13 @@ export function registerEvidencePrune(cli: CAC): void {
"Prune evidence for rules applied by the Arbitrator in the given run"
)
.action((runDir: string) => {
if (!existsSync(resolve(runDir))) {
const parsed = RunDirArgSchema.safeParse(runDir);
if (!parsed.success) { console.log(`Invalid runDir: ${parsed.error.issues[0]?.message}`); return; }
if (!existsSync(resolve(parsed.data))) {
console.log(`Run directory not found: ${runDir}`);
return;
}
const debate = parseDebateResult(resolve(runDir));
const debate = parseDebateResult(resolve(parsed.data));
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
if (!debate) {
console.log("No debate.json found — nothing to prune.");
return;
Expand Down
62 changes: 61 additions & 1 deletion src/cli/commands/internal/rule-discovery.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { join } from "node:path";
import { tmpdir } from "node:os";
import { rm } from "node:fs/promises";

import { filterDiscoveryEvidence, readDecision } from "./rule-discovery.js";
import { filterDiscoveryEvidence, readDecision, collectGapEvidence } from "./rule-discovery.js";

describe("filterDiscoveryEvidence", () => {
it("returns empty array when no matching evidence exists", () => {
Expand Down Expand Up @@ -118,3 +118,63 @@ describe("readDecision", () => {
expect(readDecision(runDir)).toBeNull();
});
});

describe("collectGapEvidence", () => {
let runDir: string;

beforeEach(() => {
runDir = mkdtempSync(join(tmpdir(), "gap-evidence-test-"));
});

afterEach(async () => {
await rm(runDir, { recursive: true, force: true });
});

it("extracts uncovered actionable gaps", () => {
writeFileSync(join(runDir, "gaps.json"), JSON.stringify({
gaps: [
{ category: "spacing", description: "padding off", actionable: true, coveredByRule: null },
{ category: "color", description: "wrong shade", actionable: true, coveredByRule: null },
{ category: "rendering", description: "font fallback", actionable: false },
{ category: "layout", description: "flex gap", actionable: true, coveredByRule: "no-auto-layout" },
],
}));

const entries = collectGapEvidence(runDir, "test-fixture");
expect(entries).toHaveLength(2);
expect(entries[0]!.category).toBe("spacing");
expect(entries[0]!.source).toBe("gap-analysis");
expect(entries[0]!.fixture).toBe("test-fixture");
expect(entries[1]!.category).toBe("color");
});

it("returns empty for no gaps.json", () => {
expect(collectGapEvidence(runDir, "fx")).toHaveLength(0);
});

it("returns empty when all gaps are covered or non-actionable", () => {
writeFileSync(join(runDir, "gaps.json"), JSON.stringify({
gaps: [
{ category: "spacing", description: "x", actionable: false },
{ category: "color", description: "y", actionable: true, coveredByRule: "raw-value" },
],
}));

expect(collectGapEvidence(runDir, "fx")).toHaveLength(0);
});

it("skips actionable gap when coveredByRule is empty string", () => {
writeFileSync(join(runDir, "gaps.json"), JSON.stringify({
gaps: [
{ category: "spacing", description: "x", actionable: true, coveredByRule: "" },
],
}));

expect(collectGapEvidence(runDir, "fx")).toHaveLength(0);
});

it("returns empty for malformed gaps.json", () => {
writeFileSync(join(runDir, "gaps.json"), "not json");
expect(collectGapEvidence(runDir, "fx")).toHaveLength(0);
});
});
102 changes: 99 additions & 3 deletions src/cli/commands/internal/rule-discovery.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,13 @@
import { existsSync, readFileSync, statSync, writeFileSync } from "node:fs";
import { join, resolve } from "node:path";
import type { CAC } from "cac";
import { loadDiscoveryEvidence } from "../../../agents/evidence-collector.js";
import { loadDiscoveryEvidence, appendDiscoveryEvidence } from "../../../agents/evidence-collector.js";
import type { DiscoveryEvidenceEntry } from "../../../agents/evidence-collector.js";
import { DecisionFileSchema } from "../../../agents/contracts/evidence.js";
import { z } from "zod";

const RunDirArgSchema = z.string().trim().min(1, "runDir is required");
const KeywordArgSchema = z.string().trim().min(1, "keyword is required");

// ─── discovery-filter-evidence ──────────────────────────────────────────────

Expand Down Expand Up @@ -39,8 +43,10 @@ export function registerFilterDiscoveryEvidence(cli: CAC): void {
)
.option("--run-dir <path>", "Write filtered evidence to run directory")
.action((keyword: string, options: { runDir?: string }) => {
const kParsed = KeywordArgSchema.safeParse(keyword);
if (!kParsed.success) { console.log(`Invalid keyword: ${kParsed.error.issues[0]?.message}`); return; }
try {
const filtered = filterDiscoveryEvidence(keyword);
const filtered = filterDiscoveryEvidence(kParsed.data);

if (options.runDir) {
const dir = resolve(options.runDir);
Expand Down Expand Up @@ -110,7 +116,9 @@ export function registerApplyDecision(cli: CAC): void {
"Read decision.json and output the action (commit/revert/adjust)"
)
.action((runDir: string) => {
const dir = resolve(runDir);
const parsed = RunDirArgSchema.safeParse(runDir);
if (!parsed.success) { console.log(`Invalid runDir: ${parsed.error.issues[0]?.message}`); return; }
const dir = resolve(parsed.data);
if (!existsSync(dir) || !statSync(dir).isDirectory()) {
console.log(`Run directory not found or is not a directory: ${runDir}`);
return;
Expand All @@ -125,3 +133,91 @@ export function registerApplyDecision(cli: CAC): void {
console.log(JSON.stringify(result));
});
}

// ─── calibrate-collect-gap-evidence ─────────────────────────────────────────

const GapSchema = z.object({
category: z.string(),
description: z.string(),
actionable: z.boolean(),
coveredByRule: z.unknown().optional(),
}).passthrough();

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Require an explicit coveredByRule: null before importing a gap.

The selection contract here is “actionable and coveredByRule === null”. With coveredByRule optional plus the != null check, gaps where the field is simply missing are treated as uncovered and get appended anyway. That turns older or malformed gaps.json into false-positive discovery evidence.

♻️ Suggested contract tightening
 const GapSchema = z.object({
   category: z.string(),
   description: z.string(),
   actionable: z.boolean(),
-  coveredByRule: z.unknown().optional(),
+  coveredByRule: z.unknown().nullable(),
 }).passthrough();
...
-    if (gap.coveredByRule != null) continue;
+    if (gap.coveredByRule !== null) continue;

Also applies to: 171-175

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/cli/commands/internal/rule-discovery.ts` around lines 143 - 145, The
schema currently allows coveredByRule to be missing because it uses
z.unknown().optional(), so gaps with a missing field are treated as uncovered;
change the property definition(s) for coveredByRule in the Zod schemas in
rule-discovery.ts from z.unknown().optional() to explicitly require null (e.g.,
z.null()) so only records with coveredByRule === null pass, and update both
occurrences where coveredByRule is defined (the schema around the actionable
selection and the other schema at the later block).

const GapsFileSchema = z.object({
fixture: z.string().optional(),
gaps: z.array(GapSchema),
}).passthrough();
Comment on lines +129 to +139

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion | 🟠 Major

Rename the schema constants to SCREAMING_SNAKE_CASE.

GapSchema and GapsFileSchema are values, so they should follow the repo's constant naming rule.

♻️ Proposed rename
-const GapSchema = z.object({
+const GAP_SCHEMA = z.object({
   category: z.string(),
   description: z.string(),
   actionable: z.boolean(),
   coveredByRule: z.unknown().default(null),
 }).passthrough();

-const GapsFileSchema = z.object({
+const GAPS_FILE_SCHEMA = z.object({
   fixture: z.string().optional(),
-  gaps: z.array(GapSchema),
+  gaps: z.array(GAP_SCHEMA),
 }).passthrough();
@@
-  const parsed = GapsFileSchema.safeParse(raw);
+  const parsed = GAPS_FILE_SCHEMA.safeParse(raw);
As per coding guidelines, "Constants must use SCREAMING_SNAKE_CASE (e.g., `MY_CONSTANT`, not `myConstant`)".

Also applies to: 155-156

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/cli/commands/internal/rule-discovery.ts` around lines 129 - 139, Rename
the zod schema constants from GapSchema and GapsFileSchema to
SCREAMING_SNAKE_CASE (e.g., GAP_SCHEMA and GAPS_FILE_SCHEMA); update the
declaration identifiers and every reference/usage (including the other
occurrences around the second set of schema definitions) to the new names, and
adjust any exports/imports accordingly so the rest of the module compiles with
the new constant identifiers.


/**
* Extract uncovered actionable gaps from gaps.json and append to discovery evidence.
* Deterministic — no LLM needed.
*/
export function collectGapEvidence(runDir: string, fixture: string): DiscoveryEvidenceEntry[] {
const gapsPath = join(runDir, "gaps.json");
if (!existsSync(gapsPath)) return [];

let raw: unknown;
try {
raw = JSON.parse(readFileSync(gapsPath, "utf-8"));
} catch {
return [];
}
const parsed = GapsFileSchema.safeParse(raw);
if (!parsed.success) return [];

const timestamp = new Date().toISOString();
const entries: DiscoveryEvidenceEntry[] = [];

for (const gap of parsed.data.gaps) {
// Only actionable gaps not covered by existing rules
if (!gap.actionable) continue;
// Skip when coveredByRule is present (non-nullish); empty string counts as "marked covered"
if (gap.coveredByRule != null) continue;

entries.push({
description: gap.description,
category: gap.category,
impact: "medium",
fixture,
timestamp,
source: "gap-analysis",
});
}

return entries;
}

export function registerCollectGapEvidence(cli: CAC): void {
cli
.command(
"calibrate-collect-gap-evidence <runDir>",
"Collect uncovered actionable gaps from gaps.json into discovery evidence"
)
.action((runDir: string) => {
const parsed = RunDirArgSchema.safeParse(runDir);
if (!parsed.success) { console.log(`Invalid runDir: ${parsed.error.issues[0]?.message}`); return; }
const dir = resolve(parsed.data);
if (!existsSync(dir) || !statSync(dir).isDirectory()) {
console.log(`Run directory not found or is not a directory: ${runDir}`);
return;
}

// Extract fixture name from run dir
const dirName = dir.split(/[/\\]/).pop() ?? "";
const idx = dirName.lastIndexOf("--");
const fixture = idx === -1 ? dirName : dirName.slice(0, idx);

try {
const entries = collectGapEvidence(dir, fixture);
if (entries.length === 0) {
console.log("No uncovered actionable gaps found");
return;
}

appendDiscoveryEvidence(entries);
console.log(`Collected ${entries.length} gap evidence entries for fixture "${fixture}"`);
} catch (err) {
console.log(`Failed to collect gap evidence: ${err instanceof Error ? err.message : String(err)}`);
Comment on lines +195 to +205

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Mark append failures as command failures.

appendDiscoveryEvidence() can still throw on filesystem errors, but this catch only prints a message and exits successfully. That makes the calibration loop continue without the new evidence even though the command’s only side effect failed. Keep the stdout message if needed, but set a non-zero exit code here.

♻️ Suggested failure signaling
       } catch (err) {
         console.log(`Failed to collect gap evidence: ${err instanceof Error ? err.message : String(err)}`);
+        process.exitCode = 1;
       }

Based on learnings, the stdout-and-exit-0 convention in src/cli/commands/internal/ is for expected cases like missing run directories and parseDebateResult returning null, not unexpected persistence failures.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
try {
const entries = collectGapEvidence(dir, fixture);
if (entries.length === 0) {
console.log("No uncovered actionable gaps found");
return;
}
appendDiscoveryEvidence(entries);
console.log(`Collected ${entries.length} gap evidence entries for fixture "${fixture}"`);
} catch (err) {
console.log(`Failed to collect gap evidence: ${err instanceof Error ? err.message : String(err)}`);
try {
const entries = collectGapEvidence(dir, fixture);
if (entries.length === 0) {
console.log("No uncovered actionable gaps found");
return;
}
appendDiscoveryEvidence(entries);
console.log(`Collected ${entries.length} gap evidence entries for fixture "${fixture}"`);
} catch (err) {
console.log(`Failed to collect gap evidence: ${err instanceof Error ? err.message : String(err)}`);
process.exitCode = 1;
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/cli/commands/internal/rule-discovery.ts` around lines 210 - 220, The
catch block around collectGapEvidence/appendDiscoveryEvidence currently swallows
persistence errors and exits 0; change it so filesystem failures from
appendDiscoveryEvidence signal command failure: in the catch for the try that
calls appendDiscoveryEvidence(entries) keep the console.log of the error but
then either rethrow the error or call process.exit(1) so the process returns a
non-zero exit code; locate the try/catch that wraps collectGapEvidence and
appendDiscoveryEvidence in rule-discovery.ts and adjust the catch to emit the
same message and then fail the command (process.exit(1) or throw) instead of
returning successfully.

}
});
}
3 changes: 2 additions & 1 deletion src/cli/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ import { registerCalibrateEvaluate } from "./commands/internal/calibrate-evaluat
import { registerCalibrateGapReport } from "./commands/internal/calibrate-gap-report.js";
import { registerCalibrateRun } from "./commands/internal/calibrate-run.js";
import { registerGatherEvidence, registerFinalizeDebate } from "./commands/internal/calibrate-debate.js";
import { registerFilterDiscoveryEvidence, registerApplyDecision } from "./commands/internal/rule-discovery.js";
import { registerFilterDiscoveryEvidence, registerApplyDecision, registerCollectGapEvidence } from "./commands/internal/rule-discovery.js";
import { registerFixtureManagement, registerEvidenceEnrich, registerEvidencePrune } from "./commands/internal/fixture-management.js";

const require = createRequire(import.meta.url);
Expand Down Expand Up @@ -87,6 +87,7 @@ registerEvidenceEnrich(cli);
registerEvidencePrune(cli);
registerFilterDiscoveryEvidence(cli);
registerApplyDecision(cli);
registerCollectGapEvidence(cli);

// ============================================
// Documentation command
Expand Down
Loading