diff --git a/CHANGELOG.md b/CHANGELOG.md index 8b99b6b..2af61e4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ - 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 `clawpatch review --prompt-file` to append extra reviewer guidance from a file or stdin, 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 a11baec..365d237 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 { @@ -239,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") { if (flags["dryRun"] === true) { @@ -303,6 +304,7 @@ export async function reviewCommand( index, total: features.length, mode, + customPrompt, allowNonPendingFeatureReview: stringFlag(flags, "feature") !== undefined, }); findingIds.push(...reviewed.findingIds); @@ -594,6 +596,7 @@ type ReviewFeatureOptions = { index: number; total: number; mode: ReviewMode; + customPrompt: string | null; allowNonPendingFeatureReview: boolean; }; @@ -608,6 +611,7 @@ async function reviewFeature(options: ReviewFeatureOptions): Promise<{ findingId index, total, mode, + customPrompt, allowNonPendingFeatureReview, } = options; const started = Date.now(); @@ -634,6 +638,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); @@ -1201,6 +1206,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, "promptFile"); + 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 e49044a..894918d 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -160,6 +160,7 @@ const commandFlags = { "reasoningEffort", "skipGitRepoCheck", "dryRun", + "promptFile", "exportTribunalLedger", ]), report: new Set(["status", "severity", "feature", "project", "category", "triage", "output"]), @@ -206,6 +207,7 @@ const valueFlagNames = new Set([ "provider", "model", "reasoning-effort", + "prompt-file", "export-tribunal-ledger", "output", "status", @@ -391,6 +393,8 @@ Flags: --reasoning-effort --skip-git-repo-check --dry-run + --prompt-file appends extra reviewer guidance to the prompt; + use "-" to read from stdin --export-tribunal-ledger after the review completes, emit a single JSONL file with one line per finding shaped 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 ed80fdf..3130454 100644 --- a/src/workflow.test.ts +++ b/src/workflow.test.ts @@ -2601,6 +2601,124 @@ 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("runs review --prompt-file through the CLI entrypoint", async () => { + const root = await fixtureRoot("clawpatch-prompt-file-cli-"); + await writeFixture(root, "package.json", JSON.stringify({ name: "prompt-file-cli" })); + + await runCli(["--root", root, "--json", "--quiet", "init"]); + + await expect( + runCli([ + "--root", + root, + "--json", + "--quiet", + "review", + "--prompt-file", + join(root, "missing.md"), + ]), + ).rejects.toThrow("failed to read --prompt-file"); + }); + it("writes a tribunal-shaped JSONL ledger when --export-tribunal-ledger is set", async () => { const root = await fixtureRoot("clawpatch-export-tribunal-"); await writeFixture(