Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
40 changes: 39 additions & 1 deletion src/app.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -303,6 +304,7 @@ export async function reviewCommand(
index,
total: features.length,
mode,
customPrompt,
allowNonPendingFeatureReview: stringFlag(flags, "feature") !== undefined,
});
findingIds.push(...reviewed.findingIds);
Expand Down Expand Up @@ -594,6 +596,7 @@ type ReviewFeatureOptions = {
index: number;
total: number;
mode: ReviewMode;
customPrompt: string | null;
allowNonPendingFeatureReview: boolean;
};

Expand All @@ -608,6 +611,7 @@ async function reviewFeature(options: ReviewFeatureOptions): Promise<{ findingId
index,
total,
mode,
customPrompt,
allowNonPendingFeatureReview,
} = options;
const started = Date.now();
Expand All @@ -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);
Expand Down Expand Up @@ -1201,6 +1206,39 @@ function reviewMode(flags: Record<string, string | boolean>): ReviewMode {
throw new ClawpatchError("invalid --mode; expected default or deslopify", 2, "invalid-usage");
}

async function loadCustomReviewPrompt(
flags: Record<string, string | boolean>,
): Promise<string | null> {
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<string> {
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,
Expand Down
4 changes: 4 additions & 0 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -160,6 +160,7 @@ const commandFlags = {
"reasoningEffort",
"skipGitRepoCheck",
"dryRun",
"promptFile",
"exportTribunalLedger",
]),
report: new Set(["status", "severity", "feature", "project", "category", "triage", "output"]),
Expand Down Expand Up @@ -206,6 +207,7 @@ const valueFlagNames = new Set([
"provider",
"model",
"reasoning-effort",
"prompt-file",
"export-tribunal-ledger",
"output",
"status",
Expand Down Expand Up @@ -391,6 +393,8 @@ Flags:
--reasoning-effort <none|minimal|low|medium|high|xhigh>
--skip-git-repo-check
--dry-run
--prompt-file <path> appends extra reviewer guidance to the prompt;
use "-" to read from stdin
--export-tribunal-ledger <path>
after the review completes, emit a single
JSONL file with one line per finding shaped
Expand Down
11 changes: 10 additions & 1 deletion src/prompt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,13 +60,22 @@ export async function buildReviewPrompt(
feature: FeatureRecord,
config: ClawpatchConfig,
mode: ReviewMode = "default",
customPrompt: string | null = null,
): Promise<string> {
const owned = feature.ownedFiles.slice(0, config.review.maxOwnedFiles);
const context = feature.contextFiles.slice(0, config.review.maxContextFiles);
const fileBlocks: string[] = [];
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.
Expand All @@ -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
Expand Down
118 changes: 118 additions & 0 deletions src/workflow.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down