@kal-elsam/kairo-runtime 0.6.0 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +24 -0
- package/package.json +1 -1
- package/scripts/cockpit-smoke.mjs +1 -1
- package/src/cli.js +59 -0
- package/src/global/ink/cockpit-controller.js +10 -0
- package/src/global/ink/cockpit-focus.js +18 -3
- package/src/global/ink/cockpit-models.js +8 -1
- package/src/global/ink/cockpit-reviews.js +62 -0
- package/src/global/ink/cockpit-runs.js +11 -2
- package/src/global/ink/cockpit-views.js +19 -2
- package/src/global/ink/orchestrator-app.js +23 -3
- package/src/global/ink/orchestrator-state.js +2 -0
- package/src/global/ink/use-orchestrator-data.js +30 -0
- package/src/global/paths.js +1 -0
- package/src/global/runtime/execution-adapters/codex.js +2 -1
- package/src/global/runtime/execution-adapters/create-execution-adapter.js +1 -0
- package/src/global/runtime/execution-adapters/index.js +1 -0
- package/src/global/runtime/execution-adapters/pi.js +2 -1
- package/src/global/runtime/review/index.js +37 -0
- package/src/global/runtime/review/review-cli.js +150 -0
- package/src/global/runtime/review/review-codex.js +132 -0
- package/src/global/runtime/review/review-exec.js +136 -0
- package/src/global/runtime/review/review-fs.js +52 -0
- package/src/global/runtime/review/review-git.js +212 -0
- package/src/global/runtime/review/review-patch.js +122 -0
- package/src/global/runtime/review/review-pi.js +168 -0
- package/src/global/runtime/review/review-receipts.js +128 -0
- package/src/global/runtime/review/review-runner.js +119 -0
- package/src/global/runtime/review/review-types.js +108 -0
- package/src/global/runtime/review/review-validate.js +280 -0
- package/src/global/runtime/write-atomic-json.js +24 -20
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import { execFile as execFileCb } from "node:child_process";
|
|
2
|
+
import { promisify } from "node:util";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { REVIEW_SCOPE_MODES } from "./review-types.js";
|
|
5
|
+
import { ReviewExecError } from "./review-exec.js";
|
|
6
|
+
import { readReviewRegularFile } from "./review-fs.js";
|
|
7
|
+
|
|
8
|
+
export const REVIEW_PATCH_ERROR_CODES = Object.freeze({
|
|
9
|
+
INVALID_CWD: "invalid_cwd", GIT_FAILED: "git_failed"
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
const defaultExecFile = promisify(execFileCb);
|
|
13
|
+
|
|
14
|
+
async function gitDiff(cwd, args, execFileImpl) {
|
|
15
|
+
try {
|
|
16
|
+
const { stdout } = await execFileImpl("git", args, {
|
|
17
|
+
cwd, encoding: "utf8", maxBuffer: 8 * 1024 * 1024
|
|
18
|
+
});
|
|
19
|
+
return stdout ?? "";
|
|
20
|
+
} catch (error) {
|
|
21
|
+
if ((error?.code === 1 || error?.status === 1) && typeof error.stdout === "string") {
|
|
22
|
+
return error.stdout;
|
|
23
|
+
}
|
|
24
|
+
throw new ReviewExecError(String(error?.stderr ?? error?.message ?? error).trim() || "git failed", {
|
|
25
|
+
code: REVIEW_PATCH_ERROR_CODES.GIT_FAILED, details: { args }
|
|
26
|
+
});
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function unquoteGitPath(path) {
|
|
31
|
+
if (path.startsWith("\"") && path.endsWith("\"")) {
|
|
32
|
+
try { return JSON.parse(path); } catch { return path.slice(1, -1); }
|
|
33
|
+
}
|
|
34
|
+
return path;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function pathsFromGitDiffHeader(line) {
|
|
38
|
+
const match = /^diff --git a\/(.+) b\/(.+)$/.exec(line);
|
|
39
|
+
if (!match) return [];
|
|
40
|
+
return [unquoteGitPath(match[1]), unquoteGitPath(match[2])];
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Keep only unified-diff sections whose every a/b endpoint is admitted. */
|
|
44
|
+
export function filterDiffToAdmittedPaths(diffText, admittedPaths) {
|
|
45
|
+
const admitted = new Set(admittedPaths);
|
|
46
|
+
if (admitted.size === 0) return "";
|
|
47
|
+
const out = [];
|
|
48
|
+
let keep = false;
|
|
49
|
+
for (const line of String(diffText ?? "").split(/\r?\n/)) {
|
|
50
|
+
if (line.startsWith("diff --git ")) {
|
|
51
|
+
const paths = pathsFromGitDiffHeader(line);
|
|
52
|
+
keep = paths.length > 0 && paths.every((path) => admitted.has(path));
|
|
53
|
+
}
|
|
54
|
+
if (keep) out.push(line);
|
|
55
|
+
}
|
|
56
|
+
while (out.length && out.at(-1) === "") out.pop();
|
|
57
|
+
return out.length ? `${out.join("\n")}\n` : "";
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function synthesizeNewFileDiff(path, content) {
|
|
61
|
+
const raw = String(content);
|
|
62
|
+
const body = raw.endsWith("\n") ? raw.slice(0, -1).split("\n") : raw.split("\n");
|
|
63
|
+
const hunk = body.length === 0
|
|
64
|
+
? ["@@ -0,0 +0,0 @@"]
|
|
65
|
+
: [`@@ -0,0 +1,${body.length} @@`, ...body.map((line) => `+${line}`)];
|
|
66
|
+
return [
|
|
67
|
+
`diff --git a/${path} b/${path}`, "new file mode 100644", "--- /dev/null", `+++ b/${path}`,
|
|
68
|
+
...hunk, ""
|
|
69
|
+
].join("\n");
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function admittedPaths(files) {
|
|
73
|
+
const paths = [];
|
|
74
|
+
for (const file of files) {
|
|
75
|
+
if (file.path) paths.push(file.path);
|
|
76
|
+
if (file.sourcePath) paths.push(file.sourcePath);
|
|
77
|
+
}
|
|
78
|
+
return paths;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Host-generated unified diff limited to snapshot.files paths.
|
|
83
|
+
* Covers WT (unstaged/staged/untracked/deleted), base, and commit scopes.
|
|
84
|
+
* Never includes excluded/private paths from snapshot.excluded.
|
|
85
|
+
*/
|
|
86
|
+
export async function buildScopedReviewPatch(snapshot, { execFileImpl = defaultExecFile } = {}) {
|
|
87
|
+
const cwd = snapshot?.cwd;
|
|
88
|
+
if (typeof cwd !== "string" || !cwd) {
|
|
89
|
+
throw new ReviewExecError("Scoped review patch requires snapshot.cwd.", {
|
|
90
|
+
code: REVIEW_PATCH_ERROR_CODES.INVALID_CWD
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
const files = Array.isArray(snapshot.files) ? snapshot.files : [];
|
|
94
|
+
const admitted = admittedPaths(files);
|
|
95
|
+
if (admitted.length === 0) return "";
|
|
96
|
+
|
|
97
|
+
let raw = "";
|
|
98
|
+
if (snapshot.mode === REVIEW_SCOPE_MODES.BASE) {
|
|
99
|
+
raw = await gitDiff(cwd, ["diff", `${snapshot.base}...HEAD`, "--", ...admitted], execFileImpl);
|
|
100
|
+
} else if (snapshot.mode === REVIEW_SCOPE_MODES.COMMIT) {
|
|
101
|
+
raw = await gitDiff(
|
|
102
|
+
cwd, ["show", "--format=", "--patch", snapshot.commit, "--", ...admitted], execFileImpl
|
|
103
|
+
);
|
|
104
|
+
} else {
|
|
105
|
+
raw = [
|
|
106
|
+
await gitDiff(cwd, ["diff", "--", ...admitted], execFileImpl),
|
|
107
|
+
await gitDiff(cwd, ["diff", "--cached", "--", ...admitted], execFileImpl)
|
|
108
|
+
].join("");
|
|
109
|
+
for (const file of files) {
|
|
110
|
+
if (file.status !== "??") continue;
|
|
111
|
+
try {
|
|
112
|
+
const buffer = await readReviewRegularFile(join(cwd, file.path));
|
|
113
|
+
raw += synthesizeNewFileDiff(file.path, buffer.toString("utf8"));
|
|
114
|
+
} catch (error) {
|
|
115
|
+
if (error?.code === "REVIEW_SYMLINK" || error?.code === "REVIEW_NON_REGULAR"
|
|
116
|
+
|| error?.code === "REVIEW_IDENTITY_CHANGED") continue;
|
|
117
|
+
throw error;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
return filterDiffToAdmittedPaths(raw, admitted);
|
|
122
|
+
}
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
import { REVIEW_AGENTS, REVIEW_SCOPE_MODES } from "./review-types.js";
|
|
2
|
+
import { ReviewExecError, assertBoundedProcessOk, runBoundedProcess } from "./review-exec.js";
|
|
3
|
+
import { buildScopedReviewPatch } from "./review-patch.js";
|
|
4
|
+
import { validateReviewOutput } from "./review-validate.js";
|
|
5
|
+
|
|
6
|
+
export const REVIEW_PI_ERROR_CODES = Object.freeze({
|
|
7
|
+
INVALID_JSONL: "invalid_jsonl", MISSING_AGENT_MESSAGE: "missing_agent_message",
|
|
8
|
+
STREAM_ERROR: "stream_error", INVALID_CWD: "invalid_cwd"
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
const EXECUTABLE = "pi";
|
|
12
|
+
const READ_ONLY_TOOLS = "read,grep,find,ls";
|
|
13
|
+
const CLI_ENV_KEYS = Object.freeze([
|
|
14
|
+
"PATH", "HOME", "USER", "LOGNAME", "SHELL", "LANG", "LC_ALL", "LC_CTYPE", "TMPDIR", "TERM",
|
|
15
|
+
"ANTHROPIC_API_KEY", "OPENAI_API_KEY", "OPENAI_BASE_URL", "GOOGLE_API_KEY", "GEMINI_API_KEY",
|
|
16
|
+
"HTTP_PROXY", "HTTPS_PROXY", "NO_PROXY", "http_proxy", "https_proxy", "no_proxy"
|
|
17
|
+
]);
|
|
18
|
+
|
|
19
|
+
function requireSnapshotCwd(snapshot) {
|
|
20
|
+
const cwd = snapshot?.cwd;
|
|
21
|
+
if (typeof cwd !== "string" || !cwd) {
|
|
22
|
+
throw new ReviewExecError("Pi review requires snapshot.cwd.", {
|
|
23
|
+
code: REVIEW_PI_ERROR_CODES.INVALID_CWD
|
|
24
|
+
});
|
|
25
|
+
}
|
|
26
|
+
return cwd;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** JSON mode, ephemeral, read-only tools, ambient resources + project trust off. Prompt via stdin. */
|
|
30
|
+
export function buildPiReviewArgs(snapshot, { model = null } = {}) {
|
|
31
|
+
requireSnapshotCwd(snapshot);
|
|
32
|
+
const args = [
|
|
33
|
+
"--mode", "json", "--no-session",
|
|
34
|
+
"--tools", READ_ONLY_TOOLS,
|
|
35
|
+
"--no-extensions", "--no-skills", "--no-prompt-templates", "--no-context-files",
|
|
36
|
+
"--no-approve"
|
|
37
|
+
];
|
|
38
|
+
if (model) args.push("--model", String(model));
|
|
39
|
+
return args;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Preserve only env needed to start/authenticate the CLI. */
|
|
43
|
+
export function buildPiCliEnv(sourceEnv = process.env) {
|
|
44
|
+
const env = Object.create(null);
|
|
45
|
+
for (const key of CLI_ENV_KEYS) {
|
|
46
|
+
if (sourceEnv[key] != null && sourceEnv[key] !== "") env[key] = sourceEnv[key];
|
|
47
|
+
}
|
|
48
|
+
return env;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function scopeRefLine(snapshot) {
|
|
52
|
+
if (snapshot?.mode === REVIEW_SCOPE_MODES.BASE) return `base=${snapshot.base ?? ""}`;
|
|
53
|
+
if (snapshot?.mode === REVIEW_SCOPE_MODES.COMMIT) return `commit=${snapshot.commit ?? ""}`;
|
|
54
|
+
return "ref=working-tree";
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Prompt header: mode + scope ref + admitted paths (patch follows on stdin). */
|
|
58
|
+
export function buildPiReviewPrompt(snapshot) {
|
|
59
|
+
const files = Array.isArray(snapshot?.files) ? snapshot.files : [];
|
|
60
|
+
return [
|
|
61
|
+
`Bounded review mode=${snapshot?.mode ?? "working-tree"} ${scopeRefLine(snapshot)}.`,
|
|
62
|
+
"Respond JSON only: {\"findings\":[{\"severity\":\"high|medium|low\",\"title\":\"...\",\"path\":\"...\",\"line\":null,\"problem\":\"...\",\"recommendation\":\"...\"}],\"warnings\":[]}.",
|
|
63
|
+
"Cite only snapshot paths:",
|
|
64
|
+
...(files.length ? files.map((f) => `- ${f.path} (${f.status})`) : ["(none)"])
|
|
65
|
+
].join("\n");
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Stdin payload for Pi JSON mode: prompt + host-scoped patch. Never persisted. */
|
|
69
|
+
export function buildPiReviewStdin(snapshot, patch) {
|
|
70
|
+
const body = typeof patch === "string" && patch.trim() !== "" ? patch.trimEnd() : "(empty patch)";
|
|
71
|
+
return `${buildPiReviewPrompt(snapshot)}\n\nScoped patch (host-generated; review this diff only; do not run git):\n${body}\n`;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function assistantText(message) {
|
|
75
|
+
if (!Array.isArray(message?.content)) return null;
|
|
76
|
+
const text = message.content
|
|
77
|
+
.filter((block) => block?.type === "text" && typeof block.text === "string")
|
|
78
|
+
.map((block) => block.text)
|
|
79
|
+
.join("");
|
|
80
|
+
return text.trim() === "" ? null : text;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function normalizeUsage(usage) {
|
|
84
|
+
if (!usage || typeof usage !== "object") return null;
|
|
85
|
+
const input = Number.isFinite(usage.input) ? usage.input
|
|
86
|
+
: Number.isFinite(usage.inputTokens) ? usage.inputTokens
|
|
87
|
+
: Number.isFinite(usage.input_tokens) ? usage.input_tokens : null;
|
|
88
|
+
const output = Number.isFinite(usage.output) ? usage.output
|
|
89
|
+
: Number.isFinite(usage.outputTokens) ? usage.outputTokens
|
|
90
|
+
: Number.isFinite(usage.output_tokens) ? usage.output_tokens : null;
|
|
91
|
+
const total = Number.isFinite(usage.totalTokens) ? usage.totalTokens
|
|
92
|
+
: Number.isFinite(usage.total) ? usage.total
|
|
93
|
+
: Number.isFinite(usage.total_tokens) ? usage.total_tokens
|
|
94
|
+
: input != null && output != null ? input + output : null;
|
|
95
|
+
const cost = typeof usage.cost === "number" ? usage.cost
|
|
96
|
+
: typeof usage.cost?.total === "number" ? usage.cost.total : null;
|
|
97
|
+
return { inputTokens: input, outputTokens: output, totalTokens: total, cost };
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** Parse Pi JSONL: last assistant message_end + turn_end usage. Trailing partial ignored. */
|
|
101
|
+
export function parsePiReviewJsonl(stdout) {
|
|
102
|
+
let agentText = null;
|
|
103
|
+
let usage = null;
|
|
104
|
+
let streamError = null;
|
|
105
|
+
const raw = String(stdout ?? "");
|
|
106
|
+
const parts = raw.split(/\r?\n/);
|
|
107
|
+
const complete = raw.endsWith("\n") || raw.endsWith("\r\n") ? parts : parts.slice(0, -1);
|
|
108
|
+
for (const line of complete) {
|
|
109
|
+
const trimmed = line.trim();
|
|
110
|
+
if (!trimmed) continue;
|
|
111
|
+
let event;
|
|
112
|
+
try { event = JSON.parse(trimmed); }
|
|
113
|
+
catch (error) {
|
|
114
|
+
throw new ReviewExecError(`Malformed Pi JSONL: ${error.message}`, {
|
|
115
|
+
code: REVIEW_PI_ERROR_CODES.INVALID_JSONL
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
if (!event || typeof event !== "object") {
|
|
119
|
+
throw new ReviewExecError("Malformed Pi JSONL event.", {
|
|
120
|
+
code: REVIEW_PI_ERROR_CODES.INVALID_JSONL
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
if (event.type === "error") {
|
|
124
|
+
streamError = String(event.message ?? event.errorMessage ?? "Pi stream error.");
|
|
125
|
+
} else if (event.type === "message_end" && event.message?.role === "assistant") {
|
|
126
|
+
const stop = event.message.stopReason;
|
|
127
|
+
if (stop === "error" || stop === "aborted") {
|
|
128
|
+
streamError = String(event.message.errorMessage ?? `Pi stopReason ${stop}.`);
|
|
129
|
+
} else {
|
|
130
|
+
const text = assistantText(event.message);
|
|
131
|
+
if (text != null) agentText = text;
|
|
132
|
+
}
|
|
133
|
+
} else if (event.type === "turn_end") {
|
|
134
|
+
const next = normalizeUsage(event.message?.usage);
|
|
135
|
+
if (next) usage = next;
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
if (streamError) throw new ReviewExecError(streamError, { code: REVIEW_PI_ERROR_CODES.STREAM_ERROR });
|
|
139
|
+
if (typeof agentText !== "string" || agentText.trim() === "") {
|
|
140
|
+
throw new ReviewExecError("Pi JSONL missing final assistant message_end.", {
|
|
141
|
+
code: REVIEW_PI_ERROR_CODES.MISSING_AGENT_MESSAGE
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
return { agentText, usage };
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** Run Pi read-only review bound to snapshot.cwd. No receipts/drift/raw streams/patch. */
|
|
148
|
+
export async function runPiReview({
|
|
149
|
+
snapshot, model = null, env = process.env, spawnImpl,
|
|
150
|
+
timeoutMs, terminationGraceMs, killGraceMs,
|
|
151
|
+
buildPatch = buildScopedReviewPatch, runProcess = runBoundedProcess
|
|
152
|
+
} = {}) {
|
|
153
|
+
const cwd = requireSnapshotCwd(snapshot);
|
|
154
|
+
const requestedModel = model == null || model === "" ? null : String(model);
|
|
155
|
+
const patch = await buildPatch(snapshot);
|
|
156
|
+
const result = await runProcess({
|
|
157
|
+
command: EXECUTABLE, args: buildPiReviewArgs(snapshot, { model: requestedModel }), cwd,
|
|
158
|
+
env: buildPiCliEnv(env), stdin: buildPiReviewStdin(snapshot, patch),
|
|
159
|
+
spawnImpl, timeoutMs, terminationGraceMs, killGraceMs
|
|
160
|
+
});
|
|
161
|
+
assertBoundedProcessOk(result);
|
|
162
|
+
const parsed = parsePiReviewJsonl(result.stdout);
|
|
163
|
+
const validated = validateReviewOutput(parsed.agentText, snapshot);
|
|
164
|
+
return {
|
|
165
|
+
agentId: REVIEW_AGENTS.PI, model: requestedModel,
|
|
166
|
+
findings: validated.findings, warnings: validated.warnings, usage: parsed.usage
|
|
167
|
+
};
|
|
168
|
+
}
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import { existsSync } from "node:fs";
|
|
2
|
+
import { mkdir, readdir, readFile } from "node:fs/promises";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { randomBytes } from "node:crypto";
|
|
5
|
+
import { harnessHomePaths } from "../../paths.js";
|
|
6
|
+
import { writeAtomicJson } from "../write-atomic-json.js";
|
|
7
|
+
import { REVIEW_STATES } from "./review-types.js";
|
|
8
|
+
import {
|
|
9
|
+
REVIEW_VALIDATION_ERROR_CODES,
|
|
10
|
+
ReviewValidationError,
|
|
11
|
+
assertReceiptSecretFree
|
|
12
|
+
} from "./review-validate.js";
|
|
13
|
+
|
|
14
|
+
export function assertSafeReviewId(reviewId) {
|
|
15
|
+
if (typeof reviewId !== "string" || !/^rev-[a-f0-9]{16,32}$/.test(reviewId)) {
|
|
16
|
+
throw new Error(`Invalid review id "${reviewId}".`);
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function createReviewId() {
|
|
21
|
+
return `rev-${randomBytes(12).toString("hex")}`;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function reviewPaths(homeDir, reviewId) {
|
|
25
|
+
assertSafeReviewId(reviewId);
|
|
26
|
+
const reviewDir = join(harnessHomePaths(homeDir).reviewsDir, reviewId);
|
|
27
|
+
return { reviewDir, receiptPath: join(reviewDir, "receipt.json") };
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function snapshotProvenance(snapshot) {
|
|
31
|
+
return {
|
|
32
|
+
mode: snapshot.mode,
|
|
33
|
+
headSha: snapshot.headSha,
|
|
34
|
+
base: snapshot.base ?? null,
|
|
35
|
+
commit: snapshot.commit ?? null,
|
|
36
|
+
fingerprint: snapshot.fingerprint,
|
|
37
|
+
totals: snapshot.totals,
|
|
38
|
+
files: (snapshot.files ?? []).map((f) => ({
|
|
39
|
+
path: f.path,
|
|
40
|
+
sourcePath: f.sourcePath ?? null,
|
|
41
|
+
status: f.status, hash: f.hash, changedLines: f.changedLines
|
|
42
|
+
})),
|
|
43
|
+
excluded: (snapshot.excluded ?? []).map((e) => ({ path: e.path, reason: e.reason }))
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Build a v1 receipt: findings + provenance only (no prompt/diff/transcript/raw). */
|
|
48
|
+
export function buildReviewReceipt({
|
|
49
|
+
reviewId,
|
|
50
|
+
agentId,
|
|
51
|
+
model = null,
|
|
52
|
+
snapshot,
|
|
53
|
+
state = REVIEW_STATES.COMPLETED,
|
|
54
|
+
findings = [],
|
|
55
|
+
warnings = [],
|
|
56
|
+
usage = null,
|
|
57
|
+
timings = null,
|
|
58
|
+
cliVersion = null,
|
|
59
|
+
createdAt = null
|
|
60
|
+
} = {}) {
|
|
61
|
+
assertSafeReviewId(reviewId);
|
|
62
|
+
const receipt = {
|
|
63
|
+
version: 1,
|
|
64
|
+
reviewId,
|
|
65
|
+
agentId,
|
|
66
|
+
model,
|
|
67
|
+
state,
|
|
68
|
+
snapshot: snapshotProvenance(snapshot),
|
|
69
|
+
findings,
|
|
70
|
+
warnings,
|
|
71
|
+
usage,
|
|
72
|
+
timings: timings && typeof timings === "object"
|
|
73
|
+
? {
|
|
74
|
+
startedAt: timings.startedAt ?? null,
|
|
75
|
+
finishedAt: timings.finishedAt ?? null,
|
|
76
|
+
durationMs: Number.isFinite(timings.durationMs) ? timings.durationMs : null
|
|
77
|
+
}
|
|
78
|
+
: null,
|
|
79
|
+
cliVersion,
|
|
80
|
+
createdAt: createdAt ?? new Date().toISOString()
|
|
81
|
+
};
|
|
82
|
+
return assertReceiptSecretFree(receipt);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Write-once create-if-absent via exclusive link; EEXIST → RECEIPT_EXISTS. */
|
|
86
|
+
export async function saveReviewReceipt(receipt, { homeDir } = {}) {
|
|
87
|
+
const sanitized = assertReceiptSecretFree(receipt);
|
|
88
|
+
assertSafeReviewId(sanitized.reviewId);
|
|
89
|
+
const { reviewDir, receiptPath } = reviewPaths(homeDir, sanitized.reviewId);
|
|
90
|
+
await mkdir(reviewDir, { recursive: true });
|
|
91
|
+
try {
|
|
92
|
+
await writeAtomicJson(receiptPath, sanitized, { createExclusive: true });
|
|
93
|
+
} catch (error) {
|
|
94
|
+
if (error?.code === "EEXIST") {
|
|
95
|
+
throw new ReviewValidationError(`Review receipt already exists: ${sanitized.reviewId}`, {
|
|
96
|
+
code: REVIEW_VALIDATION_ERROR_CODES.RECEIPT_EXISTS,
|
|
97
|
+
details: { reviewId: sanitized.reviewId, path: receiptPath }
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
throw error;
|
|
101
|
+
}
|
|
102
|
+
return { path: receiptPath, receipt: sanitized };
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export async function loadReviewReceipt(reviewId, { homeDir } = {}) {
|
|
106
|
+
const { receiptPath } = reviewPaths(homeDir, reviewId);
|
|
107
|
+
if (!existsSync(receiptPath)) throw new Error(`Review receipt not found: ${reviewId}`);
|
|
108
|
+
return assertReceiptSecretFree(JSON.parse(await readFile(receiptPath, "utf8")));
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** Load → sort by createdAt desc, reviewId asc → apply limit. */
|
|
112
|
+
export async function listReviewReceipts({ homeDir, limit = null } = {}) {
|
|
113
|
+
const dir = harnessHomePaths(homeDir).reviewsDir;
|
|
114
|
+
if (!existsSync(dir)) return [];
|
|
115
|
+
const ids = (await readdir(dir)).filter((name) => /^rev-[a-f0-9]{16,32}$/.test(name));
|
|
116
|
+
const receipts = [];
|
|
117
|
+
for (const reviewId of ids) {
|
|
118
|
+
try {
|
|
119
|
+
receipts.push(await loadReviewReceipt(reviewId, { homeDir }));
|
|
120
|
+
} catch {
|
|
121
|
+
// Skip corrupt/partial directories fail-closed for list readers.
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
receipts.sort((a, b) => String(b.createdAt).localeCompare(String(a.createdAt))
|
|
125
|
+
|| String(a.reviewId).localeCompare(String(b.reviewId)));
|
|
126
|
+
if (limit == null) return receipts;
|
|
127
|
+
return receipts.slice(0, Math.max(0, Number(limit) || 0));
|
|
128
|
+
}
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import {
|
|
2
|
+
REVIEW_AGENTS, REVIEW_EXIT_CODES, REVIEW_SEVERITIES, REVIEW_STATES
|
|
3
|
+
} from "./review-types.js";
|
|
4
|
+
import {
|
|
5
|
+
resolveReviewSnapshot, detectReviewSnapshotDrift
|
|
6
|
+
} from "./review-git.js";
|
|
7
|
+
import {
|
|
8
|
+
buildReviewReceipt, createReviewId, saveReviewReceipt
|
|
9
|
+
} from "./review-receipts.js";
|
|
10
|
+
import { ReviewValidationError } from "./review-validate.js";
|
|
11
|
+
import { runCodexReview } from "./review-codex.js";
|
|
12
|
+
import { runPiReview } from "./review-pi.js";
|
|
13
|
+
|
|
14
|
+
export const REVIEW_RUNNER_ERROR_CODES = Object.freeze({
|
|
15
|
+
UNKNOWN_AGENT: "unknown_agent",
|
|
16
|
+
CANCELLED: "cancelled"
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
const SEVERITY_RANK = Object.freeze({
|
|
20
|
+
[REVIEW_SEVERITIES.HIGH]: 3,
|
|
21
|
+
[REVIEW_SEVERITIES.MEDIUM]: 2,
|
|
22
|
+
[REVIEW_SEVERITIES.LOW]: 1
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
export class ReviewRunnerError extends Error {
|
|
26
|
+
constructor(message, { code, details = null } = {}) {
|
|
27
|
+
super(message);
|
|
28
|
+
this.name = "ReviewRunnerError";
|
|
29
|
+
this.code = code;
|
|
30
|
+
this.details = details;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Reject unknown agents before any Git snapshot work. */
|
|
35
|
+
export function resolveReviewAgent(agent) {
|
|
36
|
+
const id = String(agent ?? "").trim().toLowerCase();
|
|
37
|
+
if (id === REVIEW_AGENTS.CODEX || id === REVIEW_AGENTS.PI) return id;
|
|
38
|
+
throw new ReviewRunnerError(
|
|
39
|
+
`Unknown review agent "${agent ?? ""}". Use --agent codex|pi.`,
|
|
40
|
+
{ code: REVIEW_RUNNER_ERROR_CODES.UNKNOWN_AGENT, details: { agent } }
|
|
41
|
+
);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function resolveReviewExitCode({ state, findings = [], failOn = null } = {}) {
|
|
45
|
+
if (state !== REVIEW_STATES.COMPLETED) return REVIEW_EXIT_CODES.ERROR;
|
|
46
|
+
if (!failOn) return REVIEW_EXIT_CODES.OK;
|
|
47
|
+
const threshold = SEVERITY_RANK[failOn] ?? 0;
|
|
48
|
+
if (threshold === 0) return REVIEW_EXIT_CODES.OK;
|
|
49
|
+
const hit = findings.some((f) => (SEVERITY_RANK[f.severity] ?? 0) >= threshold);
|
|
50
|
+
return hit ? REVIEW_EXIT_CODES.THRESHOLD : REVIEW_EXIT_CODES.OK;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function classifyAgentError(error) {
|
|
54
|
+
if (error instanceof ReviewValidationError) return REVIEW_STATES.INVALID;
|
|
55
|
+
return REVIEW_STATES.FAILED;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Snapshot → Codex|Pi → drift revalidation → write-once receipt.
|
|
60
|
+
* Never returns/persists prompt, patch, JSONL, or transcript.
|
|
61
|
+
*/
|
|
62
|
+
export async function runReview({
|
|
63
|
+
cwd, agent, base = null, commit = null, model = null,
|
|
64
|
+
includePrivate = false, privateConfirmed = false, failOn = null,
|
|
65
|
+
homeDir, cliVersion = null,
|
|
66
|
+
resolveSnapshot = resolveReviewSnapshot,
|
|
67
|
+
detectDrift = detectReviewSnapshotDrift,
|
|
68
|
+
runCodex = runCodexReview, runPi = runPiReview,
|
|
69
|
+
saveReceipt = saveReviewReceipt, createId = createReviewId,
|
|
70
|
+
now = () => new Date().toISOString()
|
|
71
|
+
} = {}) {
|
|
72
|
+
const agentId = resolveReviewAgent(agent);
|
|
73
|
+
const reviewId = createId();
|
|
74
|
+
const startedAt = now();
|
|
75
|
+
const snapshot = await resolveSnapshot({
|
|
76
|
+
cwd, base, commit, includePrivate, privateConfirmed
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
let state = REVIEW_STATES.COMPLETED;
|
|
80
|
+
let findings = [];
|
|
81
|
+
let warnings = [];
|
|
82
|
+
let usage = null;
|
|
83
|
+
let resolvedModel = model == null || model === "" ? null : String(model);
|
|
84
|
+
|
|
85
|
+
try {
|
|
86
|
+
const result = agentId === REVIEW_AGENTS.CODEX
|
|
87
|
+
? await runCodex({ snapshot, model: resolvedModel })
|
|
88
|
+
: await runPi({ snapshot, model: resolvedModel });
|
|
89
|
+
findings = Array.isArray(result.findings) ? result.findings : [];
|
|
90
|
+
warnings = Array.isArray(result.warnings) ? result.warnings : [];
|
|
91
|
+
usage = result.usage ?? null;
|
|
92
|
+
resolvedModel = result.model ?? resolvedModel;
|
|
93
|
+
} catch (error) {
|
|
94
|
+
state = classifyAgentError(error);
|
|
95
|
+
warnings = [String(error?.message ?? error)];
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const drift = await detectDrift(snapshot, { includePrivate, privateConfirmed });
|
|
99
|
+
if (drift.stale) state = REVIEW_STATES.STALE;
|
|
100
|
+
|
|
101
|
+
const finishedAt = now();
|
|
102
|
+
const durationMs = Date.parse(finishedAt) - Date.parse(startedAt);
|
|
103
|
+
const receipt = buildReviewReceipt({
|
|
104
|
+
reviewId, agentId, model: resolvedModel, snapshot, state,
|
|
105
|
+
findings, warnings, usage,
|
|
106
|
+
timings: {
|
|
107
|
+
startedAt, finishedAt,
|
|
108
|
+
durationMs: Number.isFinite(durationMs) ? durationMs : null
|
|
109
|
+
},
|
|
110
|
+
cliVersion
|
|
111
|
+
});
|
|
112
|
+
await saveReceipt(receipt, { homeDir });
|
|
113
|
+
|
|
114
|
+
return {
|
|
115
|
+
receipt,
|
|
116
|
+
exitCode: resolveReviewExitCode({ state, findings, failOn }),
|
|
117
|
+
stale: Boolean(drift.stale)
|
|
118
|
+
};
|
|
119
|
+
}
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
|
|
3
|
+
export const REVIEW_SCOPE_MODES = Object.freeze({
|
|
4
|
+
WORKING_TREE: "working-tree", BASE: "base", COMMIT: "commit"
|
|
5
|
+
});
|
|
6
|
+
export const REVIEW_SEVERITIES = Object.freeze({ HIGH: "high", MEDIUM: "medium", LOW: "low" });
|
|
7
|
+
export const REVIEW_STATES = Object.freeze({
|
|
8
|
+
COMPLETED: "completed", FAILED: "failed", STALE: "stale", INVALID: "invalid"
|
|
9
|
+
});
|
|
10
|
+
export const REVIEW_EXIT_CODES = Object.freeze({ OK: 0, THRESHOLD: 1, ERROR: 2 });
|
|
11
|
+
export const REVIEW_AGENTS = Object.freeze({ CODEX: "codex", PI: "pi" });
|
|
12
|
+
export const REVIEW_LIMITS = Object.freeze({
|
|
13
|
+
MAX_FILES: 100, MAX_CHANGED_LINES: 400, MAX_DIFF_BYTES: 256 * 1024
|
|
14
|
+
});
|
|
15
|
+
export const REVIEW_SNAPSHOT_ERROR_CODES = Object.freeze({
|
|
16
|
+
NOT_A_GIT_REPO: "not_a_git_repo",
|
|
17
|
+
INVALID_REF: "invalid_ref",
|
|
18
|
+
INVALID_SCOPE: "invalid_scope",
|
|
19
|
+
INVALID_PATH: "invalid_path",
|
|
20
|
+
LIMIT_EXCEEDED: "limit_exceeded",
|
|
21
|
+
PRIVATE_CONSENT_REQUIRED: "private_consent_required"
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
export class ReviewSnapshotError extends Error {
|
|
25
|
+
constructor(message, { code, details = null } = {}) {
|
|
26
|
+
super(message);
|
|
27
|
+
this.name = "ReviewSnapshotError";
|
|
28
|
+
this.code = code;
|
|
29
|
+
this.details = details;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function resolveReviewScopeMode({ base = null, commit = null } = {}) {
|
|
34
|
+
if (base && commit) {
|
|
35
|
+
throw new ReviewSnapshotError("--base and --commit are mutually exclusive.", {
|
|
36
|
+
code: REVIEW_SNAPSHOT_ERROR_CODES.INVALID_SCOPE
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
if (base) return REVIEW_SCOPE_MODES.BASE;
|
|
40
|
+
if (commit) return REVIEW_SCOPE_MODES.COMMIT;
|
|
41
|
+
return REVIEW_SCOPE_MODES.WORKING_TREE;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function createFindingId({ severity, title, path, line = null, problem }) {
|
|
45
|
+
return createHash("sha256")
|
|
46
|
+
.update([severity, title, path, line ?? "", problem].map(String).join("\0"))
|
|
47
|
+
.digest("hex")
|
|
48
|
+
.slice(0, 16);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function canonicalFingerprint(parts) {
|
|
52
|
+
return createHash("sha256").update(JSON.stringify(parts)).digest("hex");
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const PRIVATE_PATH_PATTERNS = [
|
|
56
|
+
/^\.env(\.|$)/i, /(^|\/)\.env(\.|$)/i, /(^|\/)secrets?\//i, /(^|\/)credentials?\./i,
|
|
57
|
+
/\.pem$/i, /\.key$/i, /(^|\/)id_rsa/i, /(^|\/)\.npmrc$/i, /(^|\/)\.netrc$/i
|
|
58
|
+
];
|
|
59
|
+
|
|
60
|
+
export function isReviewPrivatePath(relativePath) {
|
|
61
|
+
return PRIVATE_PATH_PATTERNS.some((p) => p.test(String(relativePath ?? "").replace(/\\/g, "/")));
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function isBinaryContent(buffer) {
|
|
65
|
+
return Buffer.isBuffer(buffer) && buffer.subarray(0, Math.min(buffer.length, 8192)).includes(0);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function assertReviewPathSafe(relativePath) {
|
|
69
|
+
const raw = String(relativePath ?? "");
|
|
70
|
+
const normalized = raw.replace(/\\/g, "/");
|
|
71
|
+
if (
|
|
72
|
+
!normalized || normalized.startsWith("/") || normalized.includes("\0")
|
|
73
|
+
|| normalized.split("/").some((part) => part === ".." || part === "")
|
|
74
|
+
) {
|
|
75
|
+
throw new ReviewSnapshotError(`Unsafe review path "${raw}".`, {
|
|
76
|
+
code: REVIEW_SNAPSHOT_ERROR_CODES.INVALID_PATH, details: { path: raw }
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
return normalized;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export function assertWithinReviewLimits({
|
|
83
|
+
fileCount, changedLines, diffBytes, limits = REVIEW_LIMITS
|
|
84
|
+
} = {}) {
|
|
85
|
+
const reasons = [];
|
|
86
|
+
if (fileCount > limits.MAX_FILES) reasons.push(`files ${fileCount} > ${limits.MAX_FILES}`);
|
|
87
|
+
if (changedLines > limits.MAX_CHANGED_LINES) {
|
|
88
|
+
reasons.push(`changed lines ${changedLines} > ${limits.MAX_CHANGED_LINES}`);
|
|
89
|
+
}
|
|
90
|
+
if (diffBytes > limits.MAX_DIFF_BYTES) {
|
|
91
|
+
reasons.push(`diff bytes ${diffBytes} > ${limits.MAX_DIFF_BYTES}`);
|
|
92
|
+
}
|
|
93
|
+
if (reasons.length === 0) return;
|
|
94
|
+
throw new ReviewSnapshotError(`Review scope exceeds fail-closed limits (${reasons.join("; ")}).`, {
|
|
95
|
+
code: REVIEW_SNAPSHOT_ERROR_CODES.LIMIT_EXCEEDED,
|
|
96
|
+
details: { fileCount, changedLines, diffBytes, limits, reasons }
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export function requirePrivateConsent({
|
|
101
|
+
includePrivate = false, privateConfirmed = false, privatePaths = []
|
|
102
|
+
} = {}) {
|
|
103
|
+
if (!includePrivate || privatePaths.length === 0 || privateConfirmed) return;
|
|
104
|
+
throw new ReviewSnapshotError(
|
|
105
|
+
"Including private paths requires explicit consent (--include-private with --yes/--confirm, or interactive confirmation).",
|
|
106
|
+
{ code: REVIEW_SNAPSHOT_ERROR_CODES.PRIVATE_CONSENT_REQUIRED, details: { privatePaths } }
|
|
107
|
+
);
|
|
108
|
+
}
|