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 a443892..a11baec 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); } @@ -236,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 { @@ -253,7 +271,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 +324,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 +348,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, "exportTribunalLedger"); + 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 +729,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 +820,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 +1135,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 0489ed3..ed80fdf 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,9 +533,50 @@ 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" }); + }); + + 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 () => { @@ -541,7 +603,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 +837,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 +848,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 +900,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 +920,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 +995,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 +1176,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 +1197,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 +1212,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 +1262,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 +1287,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({ @@ -1292,7 +1387,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)); @@ -1321,13 +1419,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); @@ -1350,10 +1456,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); @@ -1436,7 +1549,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"]; @@ -1472,7 +1588,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"))); @@ -1640,7 +1758,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)); @@ -1661,7 +1782,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 () => { @@ -1669,7 +1793,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"); @@ -1689,7 +1816,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([]); @@ -1716,7 +1846,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"))); @@ -1740,7 +1872,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]; @@ -1773,7 +1907,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]!; @@ -1787,7 +1923,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, ], }; @@ -1850,7 +1992,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"))); @@ -1883,7 +2027,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"); @@ -1893,7 +2041,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"))); @@ -1927,7 +2077,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"); @@ -1937,7 +2091,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"))); @@ -1971,7 +2127,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"); @@ -1982,7 +2142,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"))); @@ -2021,7 +2183,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"); @@ -2031,7 +2197,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"))); @@ -2061,7 +2229,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]; @@ -2097,7 +2267,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]; @@ -2106,7 +2278,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), @@ -2114,7 +2288,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"]; }); @@ -2147,7 +2324,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]; @@ -2185,7 +2364,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", @@ -2207,7 +2388,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]; @@ -2220,10 +2403,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"]; }); @@ -2253,9 +2443,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"); @@ -2407,6 +2601,113 @@ 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", + exportTribunalLedger: 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("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" })); @@ -2416,7 +2717,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); @@ -2521,7 +2825,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",