From 8f6e1c7f1c570b18b0fe6fabffb34063e85143d1 Mon Sep 17 00:00:00 2001 From: Daniel Pittman Date: Sun, 17 May 2026 18:44:23 -0600 Subject: [PATCH] feat(review): --prompt-file flag for clawpatch review MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Lets callers append extra reviewer guidance to the hardcoded review prompt without forking the prompt builder. The flag accepts a file path (read with utf8) or '-' to read from stdin, and is plumbed through reviewCommand → reviewFeature → buildReviewPrompt as an optional customPrompt argument. Default behavior is unchanged when the flag is omitted: customPrompt defaults to null and the additional- guidance block is skipped entirely. Why: downstream consumers (e.g. Tribunal, which dispatches three lens-specific reviews) want to drive lens-aware prompts instead of post-hoc category bucketing. Today the prompt is fixed; this is the smallest change that unlocks that without breaking any existing user. Tests: three new cases in workflow.test.ts covering presence, ordering (guidance block lands before JSON shape + file blocks), and absence (omitting the flag leaves the prompt byte-identical to the no-arg case). --- src/app.ts | 85 ++++++++-- src/cli.ts | 4 + src/prompt.ts | 11 +- src/workflow.test.ts | 374 ++++++++++++++++++++++++++++++++++++------- 4 files changed, 406 insertions(+), 68 deletions(-) diff --git a/src/app.ts b/src/app.ts index a443892..8a9fcf3 100644 --- a/src/app.ts +++ b/src/app.ts @@ -1,4 +1,4 @@ -import { writeFile } from "node:fs/promises"; +import { readFile, writeFile } from "node:fs/promises"; import { join, resolve } from "node:path"; import { hostname } from "node:os"; import { @@ -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); } @@ -234,6 +239,7 @@ export async function reviewCommand( const config = applyProviderFlags(loaded.config, flags); const provider = providerByName(config.provider.name); const mode = reviewMode(flags); + const customPrompt = await loadCustomReviewPrompt(flags); const features = await selectReviewFeatures(loaded, flags); if (features.length === 0 && typeof flags["since"] === "string") { return { next: "no features touched by diff" }; @@ -253,7 +259,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", { @@ -281,6 +291,7 @@ export async function reviewCommand( index, total: features.length, mode, + customPrompt, allowNonPendingFeatureReview: stringFlag(flags, "feature") !== undefined, }); findingIds.push(...reviewed.findingIds); @@ -302,7 +313,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 = { @@ -489,6 +503,7 @@ type ReviewFeatureOptions = { index: number; total: number; mode: ReviewMode; + customPrompt: string | null; allowNonPendingFeatureReview: boolean; }; @@ -503,6 +518,7 @@ async function reviewFeature(options: ReviewFeatureOptions): Promise<{ findingId index, total, mode, + customPrompt, allowNonPendingFeatureReview, } = options; const started = Date.now(); @@ -529,6 +545,7 @@ async function reviewFeature(options: ReviewFeatureOptions): Promise<{ findingId lockedFeature, config, mode, + customPrompt, ); const output = await provider.review(loaded.root, prompt, providerOptions(config)); const modeFindings = reviewFindingsForMode(output.findings, mode); @@ -624,8 +641,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 +732,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 +1047,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(), + }); } } @@ -1081,6 +1113,39 @@ function reviewMode(flags: Record): ReviewMode { throw new ClawpatchError("invalid --mode; expected default or deslopify", 2, "invalid-usage"); } +async function loadCustomReviewPrompt( + flags: Record, +): Promise { + const path = stringFlag(flags, "prompt-file"); + if (path === undefined) { + return null; + } + if (path === "" || path === "-") { + return readStdinToString(); + } + try { + return await readFile(resolve(path), "utf8"); + } catch (error: unknown) { + const message = error instanceof Error ? error.message : String(error); + throw new ClawpatchError( + `failed to read --prompt-file ${path}: ${message}`, + 2, + "invalid-usage", + ); + } +} + +async function readStdinToString(): Promise { + if (process.stdin.isTTY) { + throw new ClawpatchError("--prompt-file=- requested but stdin is a TTY", 2, "invalid-usage"); + } + const chunks: Buffer[] = []; + for await (const chunk of process.stdin) { + chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)); + } + return Buffer.concat(chunks).toString("utf8"); +} + function reviewFindingsForMode( findings: ReviewOutput["findings"], mode: ReviewMode, diff --git a/src/cli.ts b/src/cli.ts index 6d40496..7ee1443 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -160,6 +160,7 @@ const commandFlags = { "reasoningEffort", "skipGitRepoCheck", "dryRun", + "promptFile", ]), 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", + "prompt-file", "output", "status", "severity", @@ -389,6 +391,8 @@ Flags: --reasoning-effort --skip-git-repo-check --dry-run + --prompt-file appends extra reviewer guidance to the prompt; + use "-" to read from stdin --json -q, --quiet `); diff --git a/src/prompt.ts b/src/prompt.ts index 6c8b68d..6831791 100644 --- a/src/prompt.ts +++ b/src/prompt.ts @@ -60,6 +60,7 @@ export async function buildReviewPrompt( feature: FeatureRecord, config: ClawpatchConfig, mode: ReviewMode = "default", + customPrompt: string | null = null, ): Promise { const owned = feature.ownedFiles.slice(0, config.review.maxOwnedFiles); const context = feature.contextFiles.slice(0, config.review.maxContextFiles); @@ -67,6 +68,14 @@ export async function buildReviewPrompt( for (const ref of [...owned, ...context]) { fileBlocks.push(await fileBlock(root, ref.path)); } + const customBlock = + customPrompt !== null && customPrompt.trim() !== "" + ? `Additional reviewer guidance (provided via --prompt-file): + +${customPrompt.trim()} + +` + : ""; return `You are reviewing one semantic feature for clawpatch. Return strict JSON only. No markdown fences. @@ -77,7 +86,7 @@ ${JSON.stringify({ name: project.name, detected: project.detected }, null, 2)} Feature: ${JSON.stringify(feature, null, 2)} -Review categories: +${customBlock}Review categories: - correctness bugs - security issues - race/concurrency bugs diff --git a/src/workflow.test.ts b/src/workflow.test.ts index 475fc12..414053e 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,105 @@ describe("workflow", () => { expect(prompt).toContain("do not report correctness, security, API contract"); }); + it("injects --prompt-file content into the review prompt", async () => { + const root = await fixtureRoot("clawpatch-prompt-file-"); + await writeFixture(root, "package.json", JSON.stringify({ name: "prompt-file" })); + await writeFixture(root, "src/index.ts", "export function main() { return 1; }\n"); + const context = await makeContext(testOptions(root)); + + await initCommand(context, {}); + const project = await readProject(statePaths(join(root, ".clawpatch"))); + expect(project).toBeDefined(); + const promptWithCustom = await buildReviewPrompt( + root, + project!, + { + schemaVersion: 1, + featureId: "feat_prompt_file", + title: "prompt-file", + summary: "prompt-file", + kind: "library", + source: "test", + confidence: "high", + entrypoints: [{ path: "src/index.ts", symbol: null, route: null, command: null }], + ownedFiles: [{ path: "src/index.ts", reason: "test" }], + contextFiles: [], + tests: [], + tags: [], + trustBoundaries: [], + status: "pending", + lock: null, + findingIds: [], + patchAttemptIds: [], + analysisHistory: [], + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }, + await loadConfig(root, testOptions(root)), + "default", + "Focus exclusively on race conditions and lock ordering bugs.", + ); + + expect(promptWithCustom).toContain( + "Additional reviewer guidance (provided via --prompt-file):", + ); + expect(promptWithCustom).toContain( + "Focus exclusively on race conditions and lock ordering bugs.", + ); + // Custom guidance must land before the JSON shape and file blocks so + // the model reads it as setup, not as part of the response template. + const guidanceIdx = promptWithCustom.indexOf("Additional reviewer guidance"); + const jsonIdx = promptWithCustom.indexOf("JSON shape:"); + expect(guidanceIdx).toBeGreaterThan(0); + expect(guidanceIdx).toBeLessThan(jsonIdx); + }); + + it("leaves the review prompt unchanged when --prompt-file is omitted", async () => { + const root = await fixtureRoot("clawpatch-prompt-file-omit-"); + await writeFixture(root, "package.json", JSON.stringify({ name: "prompt-file-omit" })); + await writeFixture(root, "src/index.ts", "export function main() { return 1; }\n"); + const context = await makeContext(testOptions(root)); + + await initCommand(context, {}); + const project = await readProject(statePaths(join(root, ".clawpatch"))); + expect(project).toBeDefined(); + const baseline = await buildReviewPrompt( + root, + project!, + { + schemaVersion: 1, + featureId: "feat_prompt_file_omit", + title: "prompt-file-omit", + summary: "prompt-file-omit", + kind: "library", + source: "test", + confidence: "high", + entrypoints: [{ path: "src/index.ts", symbol: null, route: null, command: null }], + ownedFiles: [{ path: "src/index.ts", reason: "test" }], + contextFiles: [], + tests: [], + tags: [], + trustBoundaries: [], + status: "pending", + lock: null, + findingIds: [], + patchAttemptIds: [], + analysisHistory: [], + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }, + await loadConfig(root, testOptions(root)), + ); + + expect(baseline).not.toContain("Additional reviewer guidance"); + }); + + it("parses --prompt-file as a review value flag", () => { + expect(parseArgs(["review", "--prompt-file", "/tmp/foo.md"]).flags).toMatchObject({ + promptFile: "/tmp/foo.md", + }); + }); + 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 +2605,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 +2713,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",