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 @@ -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.
Expand Down
140 changes: 130 additions & 10 deletions src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Expand Down Expand Up @@ -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);
}
Expand Down Expand Up @@ -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 {
Expand All @@ -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", {
Expand Down Expand Up @@ -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 = {
Expand All @@ -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-<provider> — 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<string, string | boolean>,
paths: ReturnType<typeof statePaths>,
findingIds: string[],
currentRunId: string,
providerName: string,
): Promise<string | null> {
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<string, string | boolean>,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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(),
});
}
}

Expand Down
7 changes: 7 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",
"exportTribunalLedger",
]),
report: new Set(["status", "severity", "feature", "project", "category", "triage", "output"]),
show: new Set(["finding"]),
Expand Down Expand Up @@ -205,6 +206,7 @@ const valueFlagNames = new Set([
"provider",
"model",
"reasoning-effort",
"export-tribunal-ledger",
"output",
"status",
"severity",
Expand Down Expand Up @@ -389,6 +391,11 @@ Flags:
--reasoning-effort <none|minimal|low|medium|high|xhigh>
--skip-git-repo-check
--dry-run
--export-tribunal-ledger <path>
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
`);
Expand Down
Loading