From 65be867cec7397b4cdc623b9bd10e283fa8947fb Mon Sep 17 00:00:00 2001 From: Daniel Pittman Date: Sun, 17 May 2026 18:48:37 -0600 Subject: [PATCH 1/2] feat(review): --export-tribunal-ledger flag for clawpatch review MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit After a review completes, emit a single JSONL file with one entry per finding shaped for downstream Tribunal-style signed-ledger ingest. Each line is a self-describing record: kind literal "clawpatch-review" — discriminates from Tribunal's own "finding" / "resolution" kinds finding_id the clawpatch finding ID (stable across runs) plan_id null (clawpatch has no Tribunal plan concept) round 1 (this is the first lens-pass) agent_pubkey null (Tribunal signs on ingest, not clawpatch) agent_label clawpatch- — stable source attribution severity clawpatch's 4-tier severity category clawpatch's category claim_hash the clawpatch finding signature (stable dedup key) claim_uri null stake null timestamp finding.updatedAt signature null run_id the clawpatch run ID Why: downstream consumers that ingest clawpatch findings into a separate signed ledger currently read .clawpatch/findings/.json one file per finding after the review completes. For 100+ findings on a large repo that's measurable I/O. This flag skips the per-file round trip and emits the data shaped for direct ingest. Behavior: - Flag omitted: nothing is written, no extra work, and the result object does not contain an exportTribunalLedger key (omitted via conditional spread). - Flag with a non-empty path: file is written via writeFile in the same run, path resolved against cwd. Empty findings array writes a zero-byte file. - Flag with empty string: ClawpatchError (exit 2 / invalid-usage). Tests: three new cases in workflow.test.ts covering presence, absence (no key in result), and argv parsing of the flag. --- src/app.ts | 125 ++++++++++++++-- src/cli.ts | 7 + src/workflow.test.ts | 343 ++++++++++++++++++++++++++++++++++++------- 3 files changed, 409 insertions(+), 66 deletions(-) diff --git a/src/app.ts b/src/app.ts index a443892..7ca043c 100644 --- a/src/app.ts +++ b/src/app.ts @@ -97,7 +97,10 @@ export async function initCommand( if (previous !== null && flags["force"] !== true) { throw new ClawpatchError("project already initialized; use --force", 2, "already-initialized"); } - await writeProject(paths, { ...project, createdAt: previous?.createdAt ?? project.createdAt }); + await writeProject(paths, { + ...project, + createdAt: previous?.createdAt ?? project.createdAt, + }); if (previous === null || flags["force"] === true) { await writeJson(paths.config, detectedConfig); } @@ -167,7 +170,9 @@ export async function mapCommand( reason: result.decision.reason, }; } - emitProgress(context, "map", "write-start", { features: result.features.length }); + emitProgress(context, "map", "write-start", { + features: result.features.length, + }); for (const feature of result.features) { await writeFeature(loaded.paths, feature); } @@ -253,7 +258,11 @@ export async function reviewCommand( run.claimedFeatureIds = features.map((feature) => feature.featureId); await writeRun(loaded.paths, run); const findingIds: string[] = []; - const errors: Array<{ message: string; code: string | null; error: unknown }> = []; + const errors: Array<{ + message: string; + code: string | null; + error: unknown; + }> = []; const jobs = Math.min(reviewJobs(flags), Math.max(features.length, 1)); let cursor = 0; emitProgress(context, "review", "start", { @@ -302,7 +311,10 @@ export async function reviewCommand( findingIds, errors: errors.map(({ message, code }) => ({ message, code })), }); - emitProgress(context, "review", "failed", { run: currentRunId, errors: errors.length }); + emitProgress(context, "review", "failed", { + run: currentRunId, + errors: errors.length, + }); throw errors[0]?.error ?? new ClawpatchError("review failed", 1, "review-failed"); } const finished: RunRecord = { @@ -323,16 +335,96 @@ export async function reviewCommand( await readFindings(loaded.paths), await readFeatures(loaded.paths), ); + const exportPath = await maybeExportTribunalLedger( + flags, + loaded.paths, + findingIds, + currentRunId, + config.provider.name, + ); return { run: currentRunId, reviewed: features.length, findings: findingIds.length, jobs, report: reportPath, + ...(exportPath === null ? {} : { exportTribunalLedger: exportPath }), next: findingIds.length > 0 ? `clawpatch fix --finding ${findingIds[0]}` : "clawpatch status", }; } +/** + * Tribunal-style ledger export entry shape. Each line of the emitted + * JSONL file is one of these. Schema is documented inline so downstream + * consumers don't need to read clawpatch's source to map their fields: + * + * kind literal "clawpatch-review" — discriminates from + * Tribunal's own "finding" / "resolution" kinds + * finding_id the clawpatch finding ID (stable across runs) + * plan_id always null (clawpatch has no Tribunal plan concept) + * round always 1 (this is the first lens-pass) + * agent_pubkey null (Tribunal signs on ingest, not clawpatch) + * agent_label clawpatch- — gives the consumer a stable + * source attribution without leaking model identity + * severity clawpatch's 4-tier severity (consumer maps it) + * category clawpatch's category (consumer maps it) + * claim_hash the clawpatch finding signature (stable dedup key) + * claim_uri null (clawpatch keeps the body internal) + * stake null (clawpatch has no stake economy) + * timestamp finding.updatedAt (ISO-8601) + * signature null (Tribunal signs on ingest) + * + * Opt-in only — when --export-tribunal-ledger is omitted nothing is + * written and no extra work runs. + */ +async function maybeExportTribunalLedger( + flags: Record, + paths: ReturnType, + findingIds: string[], + currentRunId: string, + providerName: string, +): Promise { + const path = stringFlag(flags, "export-tribunal-ledger"); + if (path === undefined) { + return null; + } + if (path === "") { + throw new ClawpatchError( + "--export-tribunal-ledger requires a non-empty path", + 2, + "invalid-usage", + ); + } + const findings = await readFindings(paths); + const wanted = new Set(findingIds); + const lines: string[] = []; + for (const finding of findings) { + if (!wanted.has(finding.findingId)) { + continue; + } + const entry = { + kind: "clawpatch-review", + finding_id: finding.findingId, + plan_id: null, + round: 1, + agent_pubkey: null, + agent_label: `clawpatch-${providerName}`, + severity: finding.severity, + category: finding.category, + claim_hash: finding.signature, + claim_uri: null, + stake: null, + timestamp: finding.updatedAt, + signature: null, + run_id: currentRunId, + }; + lines.push(JSON.stringify(entry)); + } + const resolved = resolve(path); + await writeFile(resolved, lines.length === 0 ? "" : `${lines.join("\n")}\n`, "utf8"); + return resolved; +} + export async function reportCommand( context: AppContext, flags: Record, @@ -624,8 +716,11 @@ export async function revalidateCommand( const run = newRun(currentRunId, "revalidate", context, loaded.root, currentGit.headSha); run.findingIds = findings.map((finding) => finding.findingId); await writeRun(loaded.paths, run); - const results: Array<{ finding: string; outcome: FindingRecord["status"]; reasoning: string }> = - []; + const results: Array<{ + finding: string; + outcome: FindingRecord["status"]; + reasoning: string; + }> = []; emitProgress(context, "revalidate", "start", { run: currentRunId, findings: findings.length, @@ -712,7 +807,11 @@ export async function revalidateCommand( }; } const first = assertDefined(results[0], "missing revalidation result"); - return { finding: first.finding, outcome: first.outcome, reasoning: first.reasoning }; + return { + finding: first.finding, + outcome: first.outcome, + reasoning: first.reasoning, + }; } export async function fixCommand( @@ -1023,9 +1122,17 @@ async function refreshFeatureStatus( ["open", "uncertain"].includes(finding.status), ); if (!hasUnresolved && featureFindings.length > 0) { - await writeFeature(paths, { ...feature, status: "fixed", updatedAt: nowIso() }); + await writeFeature(paths, { + ...feature, + status: "fixed", + updatedAt: nowIso(), + }); } else if (hasUnresolved && ["fixed", "revalidated", "reviewed"].includes(feature.status)) { - await writeFeature(paths, { ...feature, status: "needs-fix", updatedAt: nowIso() }); + await writeFeature(paths, { + ...feature, + status: "needs-fix", + updatedAt: nowIso(), + }); } } diff --git a/src/cli.ts b/src/cli.ts index 6d40496..e49044a 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -160,6 +160,7 @@ const commandFlags = { "reasoningEffort", "skipGitRepoCheck", "dryRun", + "exportTribunalLedger", ]), report: new Set(["status", "severity", "feature", "project", "category", "triage", "output"]), show: new Set(["finding"]), @@ -205,6 +206,7 @@ const valueFlagNames = new Set([ "provider", "model", "reasoning-effort", + "export-tribunal-ledger", "output", "status", "severity", @@ -389,6 +391,11 @@ Flags: --reasoning-effort --skip-git-repo-check --dry-run + --export-tribunal-ledger + after the review completes, emit a single + JSONL file with one line per finding shaped + for downstream Tribunal-style signed-ledger + ingest. Opt-in; no effect when omitted. --json -q, --quiet `); diff --git a/src/workflow.test.ts b/src/workflow.test.ts index 475fc12..3326de6 100644 --- a/src/workflow.test.ts +++ b/src/workflow.test.ts @@ -190,11 +190,16 @@ describe("workflow", () => { it("rejects unknown commands and missing required flags before context setup", () => { expect(() => parseArgs(["nope"])).toThrow("unknown command: nope"); expect(() => parseArgs(["constructor"])).toThrow("unknown command: constructor"); - expect(parseArgs(["revie", "--help"])).toMatchObject({ command: "revie", help: true }); + expect(parseArgs(["revie", "--help"])).toMatchObject({ + command: "revie", + help: true, + }); expect(() => parseArgs(["show"])).toThrow("missing --finding"); expect(() => parseArgs(["triage", "--status", "fixed"])).toThrow("missing --finding"); expect(() => parseArgs(["revalidate"])).toThrow("missing --finding or --all"); - expect(parseArgs(["revalidate", "--all"]).flags).toMatchObject({ all: true }); + expect(parseArgs(["revalidate", "--all"]).flags).toMatchObject({ + all: true, + }); }); it("rejects value flags followed by another option token", () => { @@ -221,7 +226,9 @@ describe("workflow", () => { expect(() => parseArgs(["--dry-run", "clean-locks"])).toThrow( "unsupported flag for clean-locks: --dry-run", ); - expect(parseArgs(["map", "--dry-run"]).flags).toMatchObject({ dryRun: true }); + expect(parseArgs(["map", "--dry-run"]).flags).toMatchObject({ + dryRun: true, + }); expect(parseArgs(["map", "--source", "auto", "--provider", "mock"]).flags).toMatchObject({ source: "auto", provider: "mock", @@ -305,7 +312,9 @@ describe("workflow", () => { it("rejects nonexistent explicit roots before init", async () => { const root = join(await fixtureRoot("clawpatch-missing-root-parent-"), "missing"); - await expect(makeContext(testOptions(root))).rejects.toMatchObject({ exitCode: 2 }); + await expect(makeContext(testOptions(root))).rejects.toMatchObject({ + exitCode: 2, + }); }); it("resolves relative explicit roots before provider commands use them", async () => { @@ -363,8 +372,12 @@ describe("workflow", () => { expect(reviewed).toMatchObject({ findings: 1, jobs: 1 }); expect(status).toMatchObject({ openFindings: 1 }); expect(report).toMatchObject({ findings: 1 }); - expect(report).toMatchObject({ markdown: expect.stringContaining("src/index.ts:1") }); - expect(report).toMatchObject({ markdown: expect.stringContaining("test analysis:") }); + expect(report).toMatchObject({ + markdown: expect.stringContaining("src/index.ts:1"), + }); + expect(report).toMatchObject({ + markdown: expect.stringContaining("test analysis:"), + }); expect(jsonReport).toMatchObject({ findings: 1, items: [ @@ -474,7 +487,11 @@ describe("workflow", () => { await commitAll(root, "change two"); const paths = statePaths(join(root, ".clawpatch")); const features = await readFeatures(paths); - const reviewed = await reviewCommand(context, { since: "base", limit: "20", dryRun: true }); + const reviewed = await reviewCommand(context, { + since: "base", + limit: "20", + dryRun: true, + }); expect(reviewed).toMatchObject({ dryRun: true, @@ -492,7 +509,11 @@ describe("workflow", () => { await commitAll(root, "change test"); const paths = statePaths(join(root, ".clawpatch")); const features = await readFeatures(paths); - const reviewed = await reviewCommand(context, { since: "base", limit: "20", dryRun: true }); + const reviewed = await reviewCommand(context, { + since: "base", + limit: "20", + dryRun: true, + }); const selectedIds = (reviewed as { featureIds: string[] }).featureIds; expect(selectedIds).toEqual(expectedFeatureIds(features, new Set(["tests/one.test.ts"]), true)); @@ -512,7 +533,10 @@ describe("workflow", () => { await initCommand(context, {}); await mapCommand(context); - const reviewed = await reviewCommand(context, { since: "HEAD", dryRun: true }); + const reviewed = await reviewCommand(context, { + since: "HEAD", + dryRun: true, + }); expect(reviewed).toMatchObject({ next: "no features touched by diff" }); }); @@ -541,7 +565,11 @@ describe("workflow", () => { await commitAll(root, "change two and three"); const paths = statePaths(join(root, ".clawpatch")); const features = await readFeatures(paths); - const reviewed = await reviewCommand(context, { since: "base", limit: "2", dryRun: true }); + const reviewed = await reviewCommand(context, { + since: "base", + limit: "2", + dryRun: true, + }); expect(reviewed).toMatchObject({ dryRun: true, @@ -771,7 +799,10 @@ describe("workflow", () => { "REVALIDATE_UNCERTAIN", ]; for (const [index, finding] of findings.entries()) { - await writeFinding(paths, { ...finding, reasoning: markers[index] ?? "" }); + await writeFinding(paths, { + ...finding, + reasoning: markers[index] ?? "", + }); } let progress = ""; @@ -779,7 +810,11 @@ describe("workflow", () => { progress += String(chunk); return true; }); - const result = await revalidateCommand(context, { all: true, status: "open", limit: "4" }); + const result = await revalidateCommand(context, { + all: true, + status: "open", + limit: "4", + }); stderr.mockRestore(); const updated = await readFindings(paths); const features = await readFeatures(paths); @@ -827,7 +862,10 @@ describe("workflow", () => { expect(finding).toBeDefined(); await expect( - revalidateCommand(context, { finding: finding!.findingId, provider: "mock-fail" }), + revalidateCommand(context, { + finding: finding!.findingId, + provider: "mock-fail", + }), ).rejects.toThrow("mock revalidate failure"); const runs = await readRuns(paths); const failed = runs.find((run) => run.command === "revalidate"); @@ -844,7 +882,10 @@ describe("workflow", () => { await writeFixture( root, "package.json", - JSON.stringify({ name: "parallel", bin: { one: "src/one.ts", two: "src/two.ts" } }), + JSON.stringify({ + name: "parallel", + bin: { one: "src/one.ts", two: "src/two.ts" }, + }), ); await writeFixture(root, "src/one.ts", "export const one = 'TODO_BUG';\n"); await writeFixture(root, "src/two.ts", "export const two = 'TODO_BUG';\n"); @@ -916,7 +957,10 @@ describe("workflow", () => { await writeFixture( root, "package.json", - JSON.stringify({ name: "lock-write-fail", bin: { lock: "src/index.ts" } }), + JSON.stringify({ + name: "lock-write-fail", + bin: { lock: "src/index.ts" }, + }), ); await writeFixture(root, "src/index.ts", "export const value = 1;\n"); const context = await makeContext(testOptions(root)); @@ -1094,7 +1138,9 @@ describe("workflow", () => { await runCli(["--root", root, "--json", "--quiet", "init"]); const mapped = await runCli(["--root", root, "--json", "map"]); - expect(JSON.parse(mapped.stdout)).toMatchObject({ features: expect.any(Number) }); + expect(JSON.parse(mapped.stdout)).toMatchObject({ + features: expect.any(Number), + }); expect(mapped.stderr).toContain("clawpatch map start"); expect(mapped.stderr).toContain("clawpatch map mapper-start mapper=rust"); expect(mapped.stderr).toContain("clawpatch map mapper-done mapper=rust"); @@ -1113,7 +1159,9 @@ describe("workflow", () => { await runCli(["--root", root, "--json", "--quiet", "init"]); const mapped = await runCli(["--root", root, "--json", "--quiet", "map"]); - expect(JSON.parse(mapped.stdout)).toMatchObject({ features: expect.any(Number) }); + expect(JSON.parse(mapped.stdout)).toMatchObject({ + features: expect.any(Number), + }); expect(mapped.stderr).toBe(""); }); @@ -1126,7 +1174,10 @@ describe("workflow", () => { const context = await makeContext(testOptions(root)); await initCommand(context, {}); - const mapped = await mapCommand(context, { source: "auto", provider: "mock" }); + const mapped = await mapCommand(context, { + source: "auto", + provider: "mock", + }); const features = await readFeatures(statePaths(join(root, ".clawpatch"))); const agentFeature = features.find((feature) => feature.source === "agent-mapper"); @@ -1173,7 +1224,10 @@ describe("workflow", () => { const context = await makeContext(testOptions(root)); await initCommand(context, {}); - const mapped = await mapCommand(context, { source: "auto", provider: "mock" }); + const mapped = await mapCommand(context, { + source: "auto", + provider: "mock", + }); const features = await readFeatures(statePaths(join(root, ".clawpatch"))); expect(mapped).toMatchObject({ @@ -1195,7 +1249,10 @@ describe("workflow", () => { const context = await makeContext(testOptions(root)); await initCommand(context, {}); - const mapped = await mapCommand(context, { source: "auto", provider: "mock" }); + const mapped = await mapCommand(context, { + source: "auto", + provider: "mock", + }); const features = await readFeatures(statePaths(join(root, ".clawpatch"))); expect(mapped).toMatchObject({ @@ -1226,7 +1283,10 @@ describe("workflow", () => { await writeFixture( root, "package.json", - JSON.stringify({ name: "fallback-cli", bin: { fallback: "src/index.ts" } }), + JSON.stringify({ + name: "fallback-cli", + bin: { fallback: "src/index.ts" }, + }), ); await writeFixture(root, "src/index.ts", "export const value = 1;\n"); const context = await makeContext(testOptions(root)); @@ -1255,13 +1315,21 @@ describe("workflow", () => { const first = await mapWithSource(root, project, [], heuristic, { source: "agent", provider, - providerOptions: { model: null, reasoningEffort: null, skipGitRepoCheck: false }, + providerOptions: { + model: null, + reasoningEffort: null, + skipGitRepoCheck: false, + }, }); title = "Background worker package"; const second = await mapWithSource(root, project, first.features, heuristic, { source: "agent", provider, - providerOptions: { model: null, reasoningEffort: null, skipGitRepoCheck: false }, + providerOptions: { + model: null, + reasoningEffort: null, + skipGitRepoCheck: false, + }, }); expect(first.features).toHaveLength(1); @@ -1284,10 +1352,17 @@ describe("workflow", () => { await initCommand(context, {}); await mapCommand(context); - const mapped = await mapCommand(context, { source: "agent", provider: "mock" }); + const mapped = await mapCommand(context, { + source: "agent", + provider: "mock", + }); const features = await readFeatures(statePaths(join(root, ".clawpatch"))); - expect(mapped).toMatchObject({ source: "agent", usedAgent: true, stale: 0 }); + expect(mapped).toMatchObject({ + source: "agent", + usedAgent: true, + stale: 0, + }); expect(features.some((feature) => feature.source === "package-json-bin")).toBe(true); expect(features.some((feature) => feature.source === "agent-mapper")).toBe(true); expect(features.some((feature) => feature.status === "skipped")).toBe(false); @@ -1370,7 +1445,10 @@ describe("workflow", () => { const doctor = await doctorCommand(context, {}); expect(config.provider.reasoningEffort).toBe("xhigh"); - expect(doctor).toMatchObject({ provider: "mock", reasoningEffort: "xhigh" }); + expect(doctor).toMatchObject({ + provider: "mock", + reasoningEffort: "xhigh", + }); } finally { if (previousProvider === undefined) { delete process.env["CLAWPATCH_PROVIDER"]; @@ -1406,7 +1484,9 @@ describe("workflow", () => { await initCommand(context, {}); await mapCommand(context); - const reviewed = (await reviewCommand(context, { limit: "1" })) as { next: string }; + const reviewed = (await reviewCommand(context, { limit: "1" })) as { + next: string; + }; const finding = reviewed.next.split(" ").at(-1) ?? ""; const fixed = await fixCommand(context, { finding, dryRun: true }); const patches = await readPatchAttempts(statePaths(join(root, ".clawpatch"))); @@ -1574,7 +1654,10 @@ describe("workflow", () => { await writeFixture( root, "package.json", - JSON.stringify({ name: "file-lock-status", bin: { clean: "src/index.ts" } }), + JSON.stringify({ + name: "file-lock-status", + bin: { clean: "src/index.ts" }, + }), ); await writeFixture(root, "src/index.ts", "export const value = 1;\n"); const context = await makeContext(testOptions(root)); @@ -1595,7 +1678,10 @@ describe("workflow", () => { })}\n`, ); - expect(await statusCommand(context)).toMatchObject({ activeLocks: 1, lockFiles: 1 }); + expect(await statusCommand(context)).toMatchObject({ + activeLocks: 1, + lockFiles: 1, + }); }); it("cleans interrupted review locks through the CLI entrypoint", async () => { @@ -1603,7 +1689,10 @@ describe("workflow", () => { await writeFixture( root, "package.json", - JSON.stringify({ name: "clean-locks-cli", bin: { clean: "src/index.ts" } }), + JSON.stringify({ + name: "clean-locks-cli", + bin: { clean: "src/index.ts" }, + }), ); await writeFixture(root, "src/index.ts", "export const value = 1;\n"); @@ -1623,7 +1712,10 @@ describe("workflow", () => { const output = await runCli(["--root", root, "clean-locks", "--json"]); const cleaned = (await readFeatures(paths))[0]; - expect(JSON.parse(output.stdout)).toMatchObject({ cleared: 1, lockFilesCleared: 1 }); + expect(JSON.parse(output.stdout)).toMatchObject({ + cleared: 1, + lockFilesCleared: 1, + }); expect(cleaned?.status).toBe("pending"); expect(cleaned?.lock).toBeNull(); expect(await readdir(paths.locks)).toEqual([]); @@ -1650,7 +1742,9 @@ describe("workflow", () => { await initCommand(context, {}); await mapCommand(context); - const reviewed = (await reviewCommand(context, { limit: "1" })) as { next: string }; + const reviewed = (await reviewCommand(context, { limit: "1" })) as { + next: string; + }; const finding = reviewed.next.split(" ").at(-1) ?? ""; const fixed = await fixCommand(context, { finding }); const patches = await readPatchAttempts(statePaths(join(root, ".clawpatch"))); @@ -1674,7 +1768,9 @@ describe("workflow", () => { await initCommand(context, {}); await mapCommand(context); - const reviewed = (await reviewCommand(context, { limit: "1" })) as { next: string }; + const reviewed = (await reviewCommand(context, { limit: "1" })) as { + next: string; + }; const finding = reviewed.next.split(" ").at(-1) ?? ""; const paths = statePaths(join(root, ".clawpatch")); const feature = (await readFeatures(paths))[0]; @@ -1707,7 +1803,9 @@ describe("workflow", () => { await initCommand(context, {}); await mapCommand(context); - const reviewed = (await reviewCommand(context, { limit: "1" })) as { next: string }; + const reviewed = (await reviewCommand(context, { limit: "1" })) as { + next: string; + }; const findingId = reviewed.next.split(" ").at(-1) ?? ""; const paths = statePaths(join(root, ".clawpatch")); const feature = (await readFeatures(paths))[0]!; @@ -1721,7 +1819,13 @@ describe("workflow", () => { const findingWithUnownedEvidence = { ...finding, evidence: [ - { path: ".env", startLine: 1, endLine: 1, symbol: null, quote: "SECRET" }, + { + path: ".env", + startLine: 1, + endLine: 1, + symbol: null, + quote: "SECRET", + }, ...finding.evidence, ], }; @@ -1784,7 +1888,9 @@ describe("workflow", () => { await initCommand(context, {}); await mapCommand(context); - const reviewed = (await reviewCommand(context, { limit: "1" })) as { next: string }; + const reviewed = (await reviewCommand(context, { limit: "1" })) as { + next: string; + }; const finding = reviewed.next.split(" ").at(-1) ?? ""; const fixed = await fixCommand(context, { finding }); const patches = await readPatchAttempts(statePaths(join(root, ".clawpatch"))); @@ -1817,7 +1923,11 @@ describe("workflow", () => { await writeFixture( root, "package.json", - JSON.stringify({ name: "buggy", bin: { buggy: "src/index.ts" }, scripts: {} }), + JSON.stringify({ + name: "buggy", + bin: { buggy: "src/index.ts" }, + scripts: {}, + }), ); await writeFixture(root, "src/index.ts", "export const value = 'TODO_BUG';\n"); await checkCommand(root, "git add clawpatch.config.json package.json src/index.ts"); @@ -1827,7 +1937,9 @@ describe("workflow", () => { await initCommand(context, {}); await mapCommand(context); - const reviewed = (await reviewCommand(context, { limit: "1" })) as { next: string }; + const reviewed = (await reviewCommand(context, { limit: "1" })) as { + next: string; + }; const finding = reviewed.next.split(" ").at(-1) ?? ""; const fixed = await fixCommand(context, { finding }); const patches = await readPatchAttempts(statePaths(join(root, ".clawpatch"))); @@ -1861,7 +1973,11 @@ describe("workflow", () => { await writeFixture( root, "package.json", - JSON.stringify({ name: "buggy", bin: { buggy: "src/index.ts" }, scripts: {} }), + JSON.stringify({ + name: "buggy", + bin: { buggy: "src/index.ts" }, + scripts: {}, + }), ); await writeFixture(root, "src/index.ts", "export const value = 'TODO_BUG';\n"); await checkCommand(root, "git add clawpatch.config.json package.json src/index.ts"); @@ -1871,7 +1987,9 @@ describe("workflow", () => { await initCommand(context, {}); await mapCommand(context); - const reviewed = (await reviewCommand(context, { limit: "1" })) as { next: string }; + const reviewed = (await reviewCommand(context, { limit: "1" })) as { + next: string; + }; const finding = reviewed.next.split(" ").at(-1) ?? ""; const fixed = await fixCommand(context, { finding }); const patches = await readPatchAttempts(statePaths(join(root, ".clawpatch"))); @@ -1905,7 +2023,11 @@ describe("workflow", () => { await writeFixture( root, "package.json", - JSON.stringify({ name: "buggy", bin: { buggy: "src/index.ts" }, scripts: {} }), + JSON.stringify({ + name: "buggy", + bin: { buggy: "src/index.ts" }, + scripts: {}, + }), ); await writeFixture(root, "src/index.ts", "export const value = 'TODO_BUG';\n"); await writeFixture(root, "script.sh", "#!/bin/sh\necho before\n"); @@ -1916,7 +2038,9 @@ describe("workflow", () => { await initCommand(context, {}); await mapCommand(context); - const reviewed = (await reviewCommand(context, { limit: "1" })) as { next: string }; + const reviewed = (await reviewCommand(context, { limit: "1" })) as { + next: string; + }; const finding = reviewed.next.split(" ").at(-1) ?? ""; const fixed = await fixCommand(context, { finding }); const patches = await readPatchAttempts(statePaths(join(root, ".clawpatch"))); @@ -1955,7 +2079,11 @@ describe("workflow", () => { await writeFixture( root, "package.json", - JSON.stringify({ name: "buggy", bin: { buggy: "src/index.ts" }, scripts: {} }), + JSON.stringify({ + name: "buggy", + bin: { buggy: "src/index.ts" }, + scripts: {}, + }), ); await writeFixture(root, "src/index.ts", "export const value = 'TODO_BUG';\n"); await checkCommand(root, "git add clawpatch.config.json package.json src/index.ts"); @@ -1965,7 +2093,9 @@ describe("workflow", () => { await initCommand(context, {}); await mapCommand(context); - const reviewed = (await reviewCommand(context, { limit: "1" })) as { next: string }; + const reviewed = (await reviewCommand(context, { limit: "1" })) as { + next: string; + }; const finding = reviewed.next.split(" ").at(-1) ?? ""; const fixed = await fixCommand(context, { finding }); const patches = await readPatchAttempts(statePaths(join(root, ".clawpatch"))); @@ -1995,7 +2125,9 @@ describe("workflow", () => { await initCommand(context, {}); await mapCommand(context); - const reviewed = (await reviewCommand(context, { limit: "1" })) as { next: string }; + const reviewed = (await reviewCommand(context, { limit: "1" })) as { + next: string; + }; const finding = reviewed.next.split(" ").at(-1) ?? ""; const paths = statePaths(join(root, ".clawpatch")); const feature = (await readFeatures(paths))[0]; @@ -2031,7 +2163,9 @@ describe("workflow", () => { await initCommand(context, {}); await mapCommand(context); - const reviewed = (await reviewCommand(context, { limit: "1" })) as { next: string }; + const reviewed = (await reviewCommand(context, { limit: "1" })) as { + next: string; + }; const finding = reviewed.next.split(" ").at(-1) ?? ""; const paths = statePaths(join(root, ".clawpatch")); const feature = (await readFeatures(paths))[0]; @@ -2040,7 +2174,9 @@ describe("workflow", () => { ...feature!, tests: [{ path: "src/index.test.ts", command: featureCommand }], }); - await expect(fixCommand(context, { finding })).rejects.toMatchObject({ exitCode: 6 }); + await expect(fixCommand(context, { finding })).rejects.toMatchObject({ + exitCode: 6, + }); const [patches, updatedFinding] = await Promise.all([ readPatchAttempts(paths), readFinding(paths, finding), @@ -2048,7 +2184,10 @@ describe("workflow", () => { expect(patches[0]?.status).toBe("failed"); expect(patches[0]?.commandsRun).toHaveLength(1); - expect(patches[0]?.commandsRun[0]).toMatchObject({ command: featureCommand, exitCode: 7 }); + expect(patches[0]?.commandsRun[0]).toMatchObject({ + command: featureCommand, + exitCode: 7, + }); expect(updatedFinding?.status).toBe("open"); delete process.env["CLAWPATCH_PROVIDER"]; }); @@ -2081,7 +2220,9 @@ describe("workflow", () => { await initCommand(context, {}); await mapCommand(context); - const reviewed = (await reviewCommand(context, { limit: "1" })) as { next: string }; + const reviewed = (await reviewCommand(context, { limit: "1" })) as { + next: string; + }; const finding = reviewed.next.split(" ").at(-1) ?? ""; const paths = statePaths(join(root, ".clawpatch")); const feature = (await readFeatures(paths))[0]; @@ -2119,7 +2260,9 @@ describe("workflow", () => { await initCommand(context, {}); await mapCommand(context); - const reviewed = (await reviewCommand(context, { limit: "1" })) as { next: string }; + const reviewed = (await reviewCommand(context, { limit: "1" })) as { + next: string; + }; const finding = reviewed.next.split(" ").at(-1) ?? ""; await expect(fixCommand(context, { finding })).rejects.toMatchObject({ code: "dirty-worktree", @@ -2141,7 +2284,9 @@ describe("workflow", () => { await initCommand(context, {}); await mapCommand(context); - const reviewed = (await reviewCommand(context, { limit: "1" })) as { next: string }; + const reviewed = (await reviewCommand(context, { limit: "1" })) as { + next: string; + }; const finding = reviewed.next.split(" ").at(-1) ?? ""; const paths = statePaths(join(root, ".clawpatch")); const feature = (await readFeatures(paths))[0]; @@ -2154,10 +2299,17 @@ describe("workflow", () => { }, ], }); - const fixed = await fixCommand(context, { finding, skipGitRepoCheck: true }); + const fixed = await fixCommand(context, { + finding, + skipGitRepoCheck: true, + }); const patches = await readPatchAttempts(paths); - expect(fixed).toMatchObject({ dryRun: false, status: "applied", filesChanged: 1 }); + expect(fixed).toMatchObject({ + dryRun: false, + status: "applied", + filesChanged: 1, + }); expect(patches[0]?.filesChanged).toEqual(["src/index.ts"]); delete process.env["CLAWPATCH_PROVIDER"]; }); @@ -2187,9 +2339,13 @@ describe("workflow", () => { await initCommand(context, {}); await mapCommand(context); - const reviewed = (await reviewCommand(context, { limit: "1" })) as { next: string }; + const reviewed = (await reviewCommand(context, { limit: "1" })) as { + next: string; + }; const finding = reviewed.next.split(" ").at(-1) ?? ""; - await expect(fixCommand(context, { finding })).rejects.toMatchObject({ exitCode: 6 }); + await expect(fixCommand(context, { finding })).rejects.toMatchObject({ + exitCode: 6, + }); const patches = await readPatchAttempts(statePaths(join(root, ".clawpatch"))); expect(patches[0]?.status).toBe("failed"); @@ -2341,6 +2497,74 @@ describe("workflow", () => { expect(prompt).toContain("do not report correctness, security, API contract"); }); + it("writes a tribunal-shaped JSONL ledger when --export-tribunal-ledger is set", async () => { + const root = await fixtureRoot("clawpatch-export-tribunal-"); + await writeFixture( + root, + "package.json", + JSON.stringify({ + name: "export-tribunal", + bin: { app: "src/index.ts" }, + scripts: { test: "vitest run" }, + }), + ); + await writeFixture(root, "tsconfig.json", "{}"); + await writeFixture(root, "src/index.ts", "export const value = 'TODO_BUG';\n"); + process.env["CLAWPATCH_PROVIDER"] = "mock"; + const context = await makeContext(testOptions(root)); + + await initCommand(context, {}); + await mapCommand(context); + const exportPath = join(root, "tribunal-export.jsonl"); + const reviewed = (await reviewCommand(context, { + limit: "1", + "export-tribunal-ledger": exportPath, + })) as { findings: number; exportTribunalLedger?: string }; + + expect(reviewed.findings).toBeGreaterThan(0); + expect(reviewed.exportTribunalLedger).toBe(exportPath); + + const contents = await readFile(exportPath, "utf8"); + const lines = contents.trim().split("\n"); + expect(lines).toHaveLength(reviewed.findings); + const first = JSON.parse(lines[0]!) as Record; + expect(first).toMatchObject({ + kind: "clawpatch-review", + plan_id: null, + round: 1, + agent_pubkey: null, + agent_label: expect.stringMatching(/^clawpatch-/u), + claim_uri: null, + stake: null, + signature: null, + }); + expect(first["finding_id"]).toEqual(expect.stringMatching(/^fnd_/u)); + expect(first["claim_hash"]).toEqual(expect.any(String)); + expect(first["timestamp"]).toEqual(expect.any(String)); + delete process.env["CLAWPATCH_PROVIDER"]; + }); + + it("omits exportTribunalLedger from the result when the flag is absent", async () => { + const root = await fixtureRoot("clawpatch-export-tribunal-omit-"); + await writeFixture(root, "package.json", JSON.stringify({ name: "export-omit" })); + await writeFixture(root, "tsconfig.json", "{}"); + await writeFixture(root, "src/index.ts", "export const value = 'ok';\n"); + process.env["CLAWPATCH_PROVIDER"] = "mock"; + const context = await makeContext(testOptions(root)); + + await initCommand(context, {}); + await mapCommand(context); + const reviewed = (await reviewCommand(context, { limit: "1" })) as Record; + expect(Object.hasOwn(reviewed, "exportTribunalLedger")).toBe(false); + delete process.env["CLAWPATCH_PROVIDER"]; + }); + + it("parses --export-tribunal-ledger as a review value flag", () => { + expect(parseArgs(["review", "--export-tribunal-ledger", "/tmp/out.jsonl"]).flags).toMatchObject( + { exportTribunalLedger: "/tmp/out.jsonl" }, + ); + }); + it("filters non-simplification findings in deslopify mode", async () => { const root = await fixtureRoot("clawpatch-deslopify-filter-"); await writeFixture(root, "package.json", JSON.stringify({ name: "deslopify-filter" })); @@ -2350,7 +2574,10 @@ describe("workflow", () => { await initCommand(context, {}); await mapCommand(context); - const reviewed = await reviewCommand(context, { limit: "1", mode: "deslopify" }); + const reviewed = await reviewCommand(context, { + limit: "1", + mode: "deslopify", + }); const paths = statePaths(join(root, ".clawpatch")); const findings = await readFindings(paths); @@ -2455,7 +2682,9 @@ describe("workflow", () => { await initCommand(context, {}); await mapCommand(context); - const reviewed = (await reviewCommand(context, { limit: "1" })) as { next: string }; + const reviewed = (await reviewCommand(context, { limit: "1" })) as { + next: string; + }; const finding = reviewed.next.split(" ").at(-1) ?? ""; await expect(fixCommand(context, { finding, provider: "mock-fail" })).rejects.toThrow( "mock fix failure", From 1458bcaabfd127c48ca7be7811e5d311b70a8491 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 18 May 2026 03:57:28 +0100 Subject: [PATCH 2/2] fix(review): wire tribunal ledger CLI export --- CHANGELOG.md | 1 + src/app.ts | 17 ++++++++-- src/workflow.test.ts | 79 +++++++++++++++++++++++++++++++++++++++++++- 3 files changed, 94 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 841645b..8b99b6b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ - Added explicit Codex reasoning effort selection via `--reasoning-effort`, `CLAWPATCH_REASONING_EFFORT`, and provider config, with `doctor` reporting the active setting. - Added `--skip-git-repo-check` for Codex-backed map, review, fix, and revalidate commands so initialized non-Git roots can run Codex, thanks @im-zayan. - Added `CLAWPATCH_CODEX_SANDBOX` for overriding Codex provider sandbox mode when the host already provides isolation, thanks @IAMSamuelRodda. +- Added `clawpatch review --export-tribunal-ledger` to emit review findings as JSONL for downstream ledger ingestion, thanks @dpdanpittman. - Added deterministic Express, Fastify, and Hono route mapping for Node projects, thanks @rohitjavvadi. - Fixed provider commands with relative `--root` paths by canonicalizing explicit roots before invoking Codex or other providers. - Added first-pass Elixir Mix/Phoenix mapping for project metadata, contexts, Phoenix web slices, runtime config, Ecto migrations, project scripts, ExUnit tests, and Mix validation defaults, thanks @tears-mysthrala. diff --git a/src/app.ts b/src/app.ts index 7ca043c..a11baec 100644 --- a/src/app.ts +++ b/src/app.ts @@ -241,7 +241,20 @@ export async function reviewCommand( const mode = reviewMode(flags); const features = await selectReviewFeatures(loaded, flags); if (features.length === 0 && typeof flags["since"] === "string") { - return { next: "no features touched by diff" }; + if (flags["dryRun"] === true) { + return { next: "no features touched by diff" }; + } + const exportPath = await maybeExportTribunalLedger( + flags, + loaded.paths, + [], + runId(), + config.provider.name, + ); + return { + ...(exportPath === null ? {} : { exportTribunalLedger: exportPath }), + next: "no features touched by diff", + }; } if (flags["dryRun"] === true) { return { @@ -384,7 +397,7 @@ async function maybeExportTribunalLedger( currentRunId: string, providerName: string, ): Promise { - const path = stringFlag(flags, "export-tribunal-ledger"); + const path = stringFlag(flags, "exportTribunalLedger"); if (path === undefined) { return null; } diff --git a/src/workflow.test.ts b/src/workflow.test.ts index 486c774..ed80fdf 100644 --- a/src/workflow.test.ts +++ b/src/workflow.test.ts @@ -541,6 +541,44 @@ describe("workflow", () => { expect(reviewed).toMatchObject({ next: "no features touched by diff" }); }); + it("writes an empty tribunal ledger when --since touches no review features", async () => { + const root = await sinceFixture("clawpatch-since-empty-tribunal-"); + const context = await makeContext(testOptions(root)); + + await initCommand(context, {}); + await mapCommand(context); + const exportPath = join(root, "empty-tribunal.jsonl"); + const reviewed = await reviewCommand(context, { + since: "HEAD", + exportTribunalLedger: exportPath, + }); + + expect(reviewed).toMatchObject({ + exportTribunalLedger: exportPath, + next: "no features touched by diff", + }); + expect(await readFile(exportPath, "utf8")).toBe(""); + }); + + it("does not write a tribunal ledger during no-op review dry-runs", async () => { + const root = await sinceFixture("clawpatch-since-empty-tribunal-dry-run-"); + const context = await makeContext(testOptions(root)); + + await initCommand(context, {}); + await mapCommand(context); + const exportPath = join(root, "dry-run-tribunal.jsonl"); + await writeFixture(root, "dry-run-tribunal.jsonl", "keep\n"); + const reviewed = await reviewCommand(context, { + since: "HEAD", + dryRun: true, + exportTribunalLedger: exportPath, + }); + + expect(reviewed).toMatchObject({ next: "no features touched by diff" }); + expect(Object.hasOwn(reviewed as Record, "exportTribunalLedger")).toBe(false); + expect(await readFile(exportPath, "utf8")).toBe("keep\n"); + }); + it("rejects invalid --since refs before running git diff", async () => { const root = await sinceFixture("clawpatch-since-invalid-"); const context = await makeContext(testOptions(root)); @@ -2584,7 +2622,7 @@ describe("workflow", () => { const exportPath = join(root, "tribunal-export.jsonl"); const reviewed = (await reviewCommand(context, { limit: "1", - "export-tribunal-ledger": exportPath, + exportTribunalLedger: exportPath, })) as { findings: number; exportTribunalLedger?: string }; expect(reviewed.findings).toBeGreaterThan(0); @@ -2631,6 +2669,45 @@ describe("workflow", () => { ); }); + it("runs review --export-tribunal-ledger through the CLI entrypoint", async () => { + const root = await fixtureRoot("clawpatch-export-tribunal-cli-"); + await writeFixture( + root, + "package.json", + JSON.stringify({ + name: "export-tribunal-cli", + bin: { app: "src/index.ts" }, + }), + ); + await writeFixture(root, "src/index.ts", "export const value = 'TODO_BUG';\n"); + process.env["CLAWPATCH_PROVIDER"] = "mock"; + + try { + await runCli(["--root", root, "--json", "--quiet", "init"]); + await runCli(["--root", root, "--json", "--quiet", "map"]); + const exportPath = join(root, "tribunal-cli.jsonl"); + const reviewed = await runCli([ + "--root", + root, + "--json", + "--quiet", + "review", + "--limit", + "1", + "--export-tribunal-ledger", + exportPath, + ]); + + expect(JSON.parse(reviewed.stdout)).toMatchObject({ + findings: 1, + exportTribunalLedger: exportPath, + }); + expect(await readFile(exportPath, "utf8")).toContain('"kind":"clawpatch-review"'); + } finally { + delete process.env["CLAWPATCH_PROVIDER"]; + } + }); + it("filters non-simplification findings in deslopify mode", async () => { const root = await fixtureRoot("clawpatch-deslopify-filter-"); await writeFixture(root, "package.json", JSON.stringify({ name: "deslopify-filter" }));