@crown-dev-studios/review-council 0.1.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 +170 -0
- package/SKILL.md +262 -0
- package/dist/cli.js +9 -0
- package/dist/interaction-queue.js +50 -0
- package/dist/orchestrate-review-council.js +772 -0
- package/dist/render-review-html.js +307 -0
- package/dist/review-session.js +77 -0
- package/dist/schemas.js +67 -0
- package/dist/types.js +1 -0
- package/package.json +48 -0
- package/references/cli-integration.md +177 -0
- package/references/output-contract.md +158 -0
- package/schemas/judge-done.schema.json +48 -0
- package/schemas/judge-verdict.schema.json +132 -0
- package/schemas/review-done.schema.json +42 -0
- package/schemas/review-findings.schema.json +114 -0
- package/templates/judge.md +51 -0
- package/templates/report.html +401 -0
- package/templates/reviewer-export.md +50 -0
|
@@ -0,0 +1,772 @@
|
|
|
1
|
+
import { execFileSync, spawn } from "node:child_process";
|
|
2
|
+
import { accessSync, constants, createWriteStream, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { resolve } from "node:path";
|
|
4
|
+
import { finished } from "node:stream/promises";
|
|
5
|
+
import { fileURLToPath } from "node:url";
|
|
6
|
+
import { parseArgs } from "node:util";
|
|
7
|
+
import { close as closeInteractionQueue, enqueue } from "./interaction-queue.js";
|
|
8
|
+
import { buildReviewPaths, createRunId, deriveReviewId, isReviewScopedRunDir, normalizeReviewTarget, validateReviewId, } from "./review-session.js";
|
|
9
|
+
import { renderRunDir } from "./render-review-html.js";
|
|
10
|
+
import { judgeDoneSchema, judgeVerdictSchema, reviewDoneSchema, reviewFindingsSchema, } from "./schemas.js";
|
|
11
|
+
const INTERACTIVE_PROMPT_RE = /(\? |: |> |y\/n|yes\/no)\s*$/i;
|
|
12
|
+
const PROMPT_SILENCE_MS = 3000;
|
|
13
|
+
const PROMPT_CHECK_INTERVAL_MS = 2000;
|
|
14
|
+
const DEFAULT_TIMEOUT_MS = 300000;
|
|
15
|
+
const DEFAULT_MAX_RETRIES = 2;
|
|
16
|
+
const REVIEW_PROFILE_TEMPLATES = {
|
|
17
|
+
default: "reviewer-export.md",
|
|
18
|
+
};
|
|
19
|
+
const JUDGE_PROFILE_TEMPLATES = {
|
|
20
|
+
default: "judge.md",
|
|
21
|
+
};
|
|
22
|
+
function nowIso() {
|
|
23
|
+
return new Date().toISOString();
|
|
24
|
+
}
|
|
25
|
+
function extractBinaryForPreflight(command) {
|
|
26
|
+
const trimmed = command.trim();
|
|
27
|
+
if (!trimmed)
|
|
28
|
+
return null;
|
|
29
|
+
const tokens = trimmed.match(/(?:[^\s"'`]+|"[^"]*"|'[^']*')+/g) ?? [];
|
|
30
|
+
for (const token of tokens) {
|
|
31
|
+
if (/^[A-Za-z_][A-Za-z0-9_]*=.*/.test(token)) {
|
|
32
|
+
continue;
|
|
33
|
+
}
|
|
34
|
+
if (/[|&;<>()`$]/.test(token)) {
|
|
35
|
+
return null;
|
|
36
|
+
}
|
|
37
|
+
return token.replaceAll(/^['"]|['"]$/g, "") || null;
|
|
38
|
+
}
|
|
39
|
+
return null;
|
|
40
|
+
}
|
|
41
|
+
function assertBinaryExists(command, cwd) {
|
|
42
|
+
const binary = extractBinaryForPreflight(command);
|
|
43
|
+
if (!binary)
|
|
44
|
+
return;
|
|
45
|
+
if (binary.includes("/")) {
|
|
46
|
+
const resolvedBinary = resolve(cwd, binary);
|
|
47
|
+
try {
|
|
48
|
+
accessSync(resolvedBinary, constants.X_OK);
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
catch {
|
|
52
|
+
throw new Error(`Required executable not found or not executable: ${binary}`);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
try {
|
|
56
|
+
execFileSync("which", [binary], { stdio: "ignore" });
|
|
57
|
+
}
|
|
58
|
+
catch {
|
|
59
|
+
throw new Error(`Required binary not found on PATH: ${binary}`);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
function renderTemplate(templatePath, variables, outputPath) {
|
|
63
|
+
const content = readFileSync(templatePath, "utf8");
|
|
64
|
+
const rendered = content.replaceAll(/\{\{([A-Z_]+)\}\}/g, (_match, key) => {
|
|
65
|
+
return variables[key] ?? `{{${key}}}`;
|
|
66
|
+
});
|
|
67
|
+
writeFileSync(outputPath, rendered);
|
|
68
|
+
return outputPath;
|
|
69
|
+
}
|
|
70
|
+
function resolvePromptSelection(packageDir, kind, profileId, overridePath) {
|
|
71
|
+
if (overridePath) {
|
|
72
|
+
const templatePath = resolve(overridePath);
|
|
73
|
+
if (!existsSync(templatePath)) {
|
|
74
|
+
throw new Error(`Prompt template override not found: ${templatePath}`);
|
|
75
|
+
}
|
|
76
|
+
return {
|
|
77
|
+
templatePath,
|
|
78
|
+
source: `override:${templatePath}`,
|
|
79
|
+
profileId,
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
const templates = kind === "review" ? REVIEW_PROFILE_TEMPLATES : JUDGE_PROFILE_TEMPLATES;
|
|
83
|
+
const templateName = templates[profileId];
|
|
84
|
+
if (!templateName) {
|
|
85
|
+
throw new Error(`Unknown ${kind} profile: ${profileId}`);
|
|
86
|
+
}
|
|
87
|
+
const templatePath = resolve(packageDir, "templates", templateName);
|
|
88
|
+
if (!existsSync(templatePath)) {
|
|
89
|
+
throw new Error(`Prompt template not found for ${kind} profile "${profileId}": ${templatePath}`);
|
|
90
|
+
}
|
|
91
|
+
return {
|
|
92
|
+
templatePath,
|
|
93
|
+
source: `${kind}-profile:${profileId}`,
|
|
94
|
+
profileId,
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
function cleanupStageFiles(stageDir, artifactNames) {
|
|
98
|
+
for (const fileName of [
|
|
99
|
+
"stdout.log",
|
|
100
|
+
"stderr.log",
|
|
101
|
+
"status.json",
|
|
102
|
+
"done.json",
|
|
103
|
+
...artifactNames,
|
|
104
|
+
]) {
|
|
105
|
+
rmSync(resolve(stageDir, fileName), { force: true, recursive: false });
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
async function runStageOnce(name, command, stageDir, workdir, timeoutMs, commandEnv) {
|
|
109
|
+
const stdoutPath = resolve(stageDir, "stdout.log");
|
|
110
|
+
const stderrPath = resolve(stageDir, "stderr.log");
|
|
111
|
+
const startedAt = nowIso();
|
|
112
|
+
const stdoutFile = createWriteStream(stdoutPath);
|
|
113
|
+
const stderrFile = createWriteStream(stderrPath);
|
|
114
|
+
const child = spawn("/bin/sh", ["-c", command], {
|
|
115
|
+
cwd: workdir,
|
|
116
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
117
|
+
env: { ...process.env, ...commandEnv },
|
|
118
|
+
});
|
|
119
|
+
child.stdout.pipe(stdoutFile);
|
|
120
|
+
child.stderr.pipe(stderrFile);
|
|
121
|
+
const rollingBufferSize = 1024;
|
|
122
|
+
let recentOutput = "";
|
|
123
|
+
let lastOutputTime = 0;
|
|
124
|
+
child.stdout.on("data", (chunk) => {
|
|
125
|
+
recentOutput = (recentOutput + chunk.toString()).slice(-rollingBufferSize);
|
|
126
|
+
lastOutputTime = Date.now();
|
|
127
|
+
});
|
|
128
|
+
const promptInterval = setInterval(() => {
|
|
129
|
+
if (lastOutputTime > 0 &&
|
|
130
|
+
Date.now() - lastOutputTime > PROMPT_SILENCE_MS &&
|
|
131
|
+
INTERACTIVE_PROMPT_RE.test(recentOutput) &&
|
|
132
|
+
child.stdin.writable) {
|
|
133
|
+
const promptText = recentOutput;
|
|
134
|
+
recentOutput = "";
|
|
135
|
+
lastOutputTime = 0;
|
|
136
|
+
enqueue({
|
|
137
|
+
stage: name,
|
|
138
|
+
prompt: promptText,
|
|
139
|
+
stdinPipe: child.stdin,
|
|
140
|
+
resolve: () => { },
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
}, PROMPT_CHECK_INTERVAL_MS);
|
|
144
|
+
let timedOut = false;
|
|
145
|
+
let killTimer = null;
|
|
146
|
+
const timeoutTimer = setTimeout(() => {
|
|
147
|
+
timedOut = true;
|
|
148
|
+
child.kill("SIGTERM");
|
|
149
|
+
killTimer = setTimeout(() => {
|
|
150
|
+
try {
|
|
151
|
+
child.kill("SIGKILL");
|
|
152
|
+
}
|
|
153
|
+
catch {
|
|
154
|
+
// Process already exited.
|
|
155
|
+
}
|
|
156
|
+
}, 5000);
|
|
157
|
+
}, timeoutMs);
|
|
158
|
+
const exitCode = await new Promise((resolveExit) => {
|
|
159
|
+
child.once("error", () => resolveExit(1));
|
|
160
|
+
child.once("close", (code) => resolveExit(timedOut ? 124 : (code ?? 1)));
|
|
161
|
+
});
|
|
162
|
+
clearTimeout(timeoutTimer);
|
|
163
|
+
if (killTimer)
|
|
164
|
+
clearTimeout(killTimer);
|
|
165
|
+
clearInterval(promptInterval);
|
|
166
|
+
try {
|
|
167
|
+
child.stdin.end();
|
|
168
|
+
}
|
|
169
|
+
catch {
|
|
170
|
+
// The pipe may already be closed.
|
|
171
|
+
}
|
|
172
|
+
stdoutFile.end();
|
|
173
|
+
stderrFile.end();
|
|
174
|
+
await Promise.all([
|
|
175
|
+
finished(stdoutFile),
|
|
176
|
+
finished(stderrFile),
|
|
177
|
+
]);
|
|
178
|
+
return {
|
|
179
|
+
exitCode,
|
|
180
|
+
timedOut,
|
|
181
|
+
startedAt,
|
|
182
|
+
finishedAt: nowIso(),
|
|
183
|
+
stdoutPath,
|
|
184
|
+
stderrPath,
|
|
185
|
+
};
|
|
186
|
+
}
|
|
187
|
+
export function evaluateStageArtifacts(stage, attempt, requireSentinel, reviewId, runId) {
|
|
188
|
+
const artifactPresence = {};
|
|
189
|
+
const requiredArtifacts = [...stage.requiredArtifacts];
|
|
190
|
+
if (requireSentinel) {
|
|
191
|
+
requiredArtifacts.push("done.json");
|
|
192
|
+
}
|
|
193
|
+
for (const artifactName of requiredArtifacts) {
|
|
194
|
+
artifactPresence[artifactName] = existsSync(resolve(stage.stageDir, artifactName));
|
|
195
|
+
}
|
|
196
|
+
const missingArtifacts = requiredArtifacts.filter((artifactName) => !artifactPresence[artifactName]);
|
|
197
|
+
if (attempt.timedOut) {
|
|
198
|
+
return {
|
|
199
|
+
success: false,
|
|
200
|
+
failureReason: "timeout",
|
|
201
|
+
artifactPresence,
|
|
202
|
+
missingArtifacts,
|
|
203
|
+
};
|
|
204
|
+
}
|
|
205
|
+
if (attempt.exitCode !== 0) {
|
|
206
|
+
return {
|
|
207
|
+
success: false,
|
|
208
|
+
failureReason: "process_failed",
|
|
209
|
+
artifactPresence,
|
|
210
|
+
missingArtifacts,
|
|
211
|
+
};
|
|
212
|
+
}
|
|
213
|
+
if (missingArtifacts.length > 0) {
|
|
214
|
+
return {
|
|
215
|
+
success: false,
|
|
216
|
+
failureReason: "missing_artifacts",
|
|
217
|
+
artifactPresence,
|
|
218
|
+
missingArtifacts,
|
|
219
|
+
};
|
|
220
|
+
}
|
|
221
|
+
const artifactPath = resolve(stage.stageDir, stage.jsonArtifactName);
|
|
222
|
+
try {
|
|
223
|
+
const raw = readJsonFile(artifactPath, stage.jsonArtifactName);
|
|
224
|
+
const parsed = stage.artifactSchema.safeParse(raw);
|
|
225
|
+
const validationErrors = [];
|
|
226
|
+
if (!parsed.success) {
|
|
227
|
+
validationErrors.push(...zodToValidationErrors(parsed.error));
|
|
228
|
+
}
|
|
229
|
+
else {
|
|
230
|
+
if (parsed.data.review_id !== reviewId) {
|
|
231
|
+
validationErrors.push({
|
|
232
|
+
path: "review_id",
|
|
233
|
+
message: `expected review_id "${reviewId}" but received "${parsed.data.review_id}"`,
|
|
234
|
+
});
|
|
235
|
+
}
|
|
236
|
+
if (parsed.data.run_id !== runId) {
|
|
237
|
+
validationErrors.push({
|
|
238
|
+
path: "run_id",
|
|
239
|
+
message: `expected run_id "${runId}" but received "${parsed.data.run_id}"`,
|
|
240
|
+
});
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
if (requireSentinel) {
|
|
244
|
+
const donePath = resolve(stage.stageDir, "done.json");
|
|
245
|
+
const doneRaw = readJsonFile(donePath, "done.json");
|
|
246
|
+
if (stage.doneSchema) {
|
|
247
|
+
const doneParsed = stage.doneSchema.safeParse(doneRaw);
|
|
248
|
+
if (!doneParsed.success) {
|
|
249
|
+
validationErrors.push(...zodToValidationErrors(doneParsed.error));
|
|
250
|
+
}
|
|
251
|
+
else {
|
|
252
|
+
if (doneParsed.data.review_id !== reviewId) {
|
|
253
|
+
validationErrors.push({
|
|
254
|
+
path: "done.review_id",
|
|
255
|
+
message: `expected review_id "${reviewId}" but received "${doneParsed.data.review_id}"`,
|
|
256
|
+
});
|
|
257
|
+
}
|
|
258
|
+
if (doneParsed.data.run_id !== runId) {
|
|
259
|
+
validationErrors.push({
|
|
260
|
+
path: "done.run_id",
|
|
261
|
+
message: `expected run_id "${runId}" but received "${doneParsed.data.run_id}"`,
|
|
262
|
+
});
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
if (validationErrors.length > 0) {
|
|
268
|
+
return {
|
|
269
|
+
success: false,
|
|
270
|
+
failureReason: "schema_validation_failed",
|
|
271
|
+
validationErrors,
|
|
272
|
+
artifactPresence,
|
|
273
|
+
missingArtifacts,
|
|
274
|
+
};
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
catch (error) {
|
|
278
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
279
|
+
return {
|
|
280
|
+
success: false,
|
|
281
|
+
failureReason: "schema_validation_failed",
|
|
282
|
+
validationErrors: [{ path: "", message }],
|
|
283
|
+
artifactPresence,
|
|
284
|
+
missingArtifacts,
|
|
285
|
+
};
|
|
286
|
+
}
|
|
287
|
+
return {
|
|
288
|
+
success: true,
|
|
289
|
+
artifactPresence,
|
|
290
|
+
missingArtifacts,
|
|
291
|
+
};
|
|
292
|
+
}
|
|
293
|
+
function writeStageStatus(statusPath, stage, reviewId, runId, command, attempt, evaluation, requireSentinel, attempts) {
|
|
294
|
+
writeFileSync(statusPath, `${JSON.stringify({
|
|
295
|
+
review_id: reviewId,
|
|
296
|
+
run_id: runId,
|
|
297
|
+
stage: stage.name,
|
|
298
|
+
command,
|
|
299
|
+
prompt_template: stage.promptTemplatePath,
|
|
300
|
+
prompt_template_source: stage.promptTemplateSource,
|
|
301
|
+
started_at: attempt.startedAt,
|
|
302
|
+
finished_at: attempt.finishedAt,
|
|
303
|
+
exit_code: attempt.exitCode,
|
|
304
|
+
require_sentinel: requireSentinel,
|
|
305
|
+
done_file_present: evaluation.artifactPresence["done.json"] ?? false,
|
|
306
|
+
required_artifacts: [...stage.requiredArtifacts, ...(requireSentinel ? ["done.json"] : [])],
|
|
307
|
+
artifact_presence: evaluation.artifactPresence,
|
|
308
|
+
missing_artifacts: evaluation.missingArtifacts,
|
|
309
|
+
success: evaluation.success,
|
|
310
|
+
failure_reason: evaluation.failureReason ?? null,
|
|
311
|
+
timed_out: attempt.timedOut,
|
|
312
|
+
attempts,
|
|
313
|
+
retried: attempts > 1,
|
|
314
|
+
validation_errors: evaluation.validationErrors ?? [],
|
|
315
|
+
stdout_log: attempt.stdoutPath,
|
|
316
|
+
stderr_log: attempt.stderrPath,
|
|
317
|
+
}, null, 2)}\n`);
|
|
318
|
+
}
|
|
319
|
+
async function runStage(stage, workdir, requireSentinel, timeoutMs, maxRetries, reviewId, runId, commandEnv) {
|
|
320
|
+
if (!stage.command) {
|
|
321
|
+
return null;
|
|
322
|
+
}
|
|
323
|
+
const statusPath = resolve(stage.stageDir, "status.json");
|
|
324
|
+
const command = stage.command;
|
|
325
|
+
let attempts = 0;
|
|
326
|
+
let lastAttempt = null;
|
|
327
|
+
let lastEvaluation = null;
|
|
328
|
+
for (let attemptIndex = 0; attemptIndex <= maxRetries; attemptIndex += 1) {
|
|
329
|
+
if (attemptIndex > 0) {
|
|
330
|
+
const delayMs = 2000 * Math.pow(2, attemptIndex - 1);
|
|
331
|
+
process.stderr.write(`[${stage.name}] retry ${attemptIndex}/${maxRetries} in ${delayMs}ms\n`);
|
|
332
|
+
await new Promise((resolveDelay) => setTimeout(resolveDelay, delayMs));
|
|
333
|
+
}
|
|
334
|
+
attempts = attemptIndex + 1;
|
|
335
|
+
cleanupStageFiles(stage.stageDir, stage.requiredArtifacts);
|
|
336
|
+
lastAttempt = await runStageOnce(stage.name, command, stage.stageDir, workdir, timeoutMs, commandEnv);
|
|
337
|
+
lastEvaluation = evaluateStageArtifacts(stage, lastAttempt, requireSentinel, reviewId, runId);
|
|
338
|
+
writeStageStatus(statusPath, stage, reviewId, runId, command, lastAttempt, lastEvaluation, requireSentinel, attempts);
|
|
339
|
+
if (lastEvaluation.success || lastAttempt.timedOut) {
|
|
340
|
+
break;
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
if (!lastAttempt || !lastEvaluation) {
|
|
344
|
+
throw new Error(`Stage ${stage.name} never executed.`);
|
|
345
|
+
}
|
|
346
|
+
return {
|
|
347
|
+
name: stage.name,
|
|
348
|
+
exit_code: lastAttempt.exitCode,
|
|
349
|
+
success: lastEvaluation.success,
|
|
350
|
+
timed_out: lastAttempt.timedOut,
|
|
351
|
+
attempts,
|
|
352
|
+
failure_reason: lastEvaluation.failureReason,
|
|
353
|
+
validation_errors: lastEvaluation.validationErrors,
|
|
354
|
+
missing_artifacts: lastEvaluation.missingArtifacts,
|
|
355
|
+
artifact_presence: lastEvaluation.artifactPresence,
|
|
356
|
+
};
|
|
357
|
+
}
|
|
358
|
+
function writeLatestRunMarker(reviewDir, runDir, reviewId, runId) {
|
|
359
|
+
writeFileSync(resolve(reviewDir, "latest-run.json"), `${JSON.stringify({
|
|
360
|
+
review_id: reviewId,
|
|
361
|
+
run_id: runId,
|
|
362
|
+
run_dir: runDir,
|
|
363
|
+
updated_at: nowIso(),
|
|
364
|
+
}, null, 2)}\n`);
|
|
365
|
+
}
|
|
366
|
+
function zodToValidationErrors(error) {
|
|
367
|
+
return error.issues.map((issue) => ({
|
|
368
|
+
path: issue.path.map(String).join("."),
|
|
369
|
+
message: issue.message,
|
|
370
|
+
}));
|
|
371
|
+
}
|
|
372
|
+
function readJsonFile(path, label) {
|
|
373
|
+
try {
|
|
374
|
+
return JSON.parse(readFileSync(path, "utf8"));
|
|
375
|
+
}
|
|
376
|
+
catch (error) {
|
|
377
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
378
|
+
throw new Error(`failed to parse ${label}: ${message}`);
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
export function parseCliOptions(args) {
|
|
382
|
+
const { values } = parseArgs({
|
|
383
|
+
args,
|
|
384
|
+
allowPositionals: false,
|
|
385
|
+
options: {
|
|
386
|
+
target: { type: "string" },
|
|
387
|
+
"review-id": { type: "string" },
|
|
388
|
+
"run-dir": { type: "string" },
|
|
389
|
+
"review-profile": { type: "string" },
|
|
390
|
+
"judge-profile": { type: "string" },
|
|
391
|
+
"claude-prompt-template": { type: "string" },
|
|
392
|
+
"codex-prompt-template": { type: "string" },
|
|
393
|
+
"judge-prompt-template": { type: "string" },
|
|
394
|
+
"claude-command": { type: "string" },
|
|
395
|
+
"codex-command": { type: "string" },
|
|
396
|
+
"judge-command": { type: "string" },
|
|
397
|
+
"allow-missing-sentinel": { type: "boolean" },
|
|
398
|
+
"skip-judge": { type: "boolean" },
|
|
399
|
+
"skip-html": { type: "boolean" },
|
|
400
|
+
"open-html": { type: "boolean" },
|
|
401
|
+
timeout: { type: "string" },
|
|
402
|
+
retries: { type: "string" },
|
|
403
|
+
help: { type: "boolean", short: "h" },
|
|
404
|
+
},
|
|
405
|
+
});
|
|
406
|
+
if (values.help) {
|
|
407
|
+
printHelp();
|
|
408
|
+
return null;
|
|
409
|
+
}
|
|
410
|
+
if (!values.target) {
|
|
411
|
+
console.error("Error: --target is required.");
|
|
412
|
+
printHelp();
|
|
413
|
+
process.exitCode = 1;
|
|
414
|
+
return null;
|
|
415
|
+
}
|
|
416
|
+
const timeoutMs = values.timeout ? parseInt(values.timeout, 10) : DEFAULT_TIMEOUT_MS;
|
|
417
|
+
if (Number.isNaN(timeoutMs) || timeoutMs <= 0) {
|
|
418
|
+
console.error(`Invalid --timeout: "${values.timeout}". Must be a positive integer (ms).`);
|
|
419
|
+
process.exitCode = 1;
|
|
420
|
+
return null;
|
|
421
|
+
}
|
|
422
|
+
const maxRetries = values.retries ? parseInt(values.retries, 10) : DEFAULT_MAX_RETRIES;
|
|
423
|
+
if (Number.isNaN(maxRetries) || maxRetries < 0) {
|
|
424
|
+
console.error(`Invalid --retries: "${values.retries}". Must be a non-negative integer.`);
|
|
425
|
+
process.exitCode = 1;
|
|
426
|
+
return null;
|
|
427
|
+
}
|
|
428
|
+
if (!values["claude-command"] && !values["codex-command"]) {
|
|
429
|
+
console.error("At least one reviewer command must be configured via --claude-command and/or --codex-command.");
|
|
430
|
+
process.exitCode = 1;
|
|
431
|
+
return null;
|
|
432
|
+
}
|
|
433
|
+
return {
|
|
434
|
+
target: values.target,
|
|
435
|
+
reviewId: values["review-id"],
|
|
436
|
+
runDir: values["run-dir"],
|
|
437
|
+
reviewProfileId: values["review-profile"] ?? "default",
|
|
438
|
+
judgeProfileId: values["judge-profile"] ?? "default",
|
|
439
|
+
claudePromptTemplate: values["claude-prompt-template"],
|
|
440
|
+
codexPromptTemplate: values["codex-prompt-template"],
|
|
441
|
+
judgePromptTemplate: values["judge-prompt-template"],
|
|
442
|
+
claudeCommand: values["claude-command"],
|
|
443
|
+
codexCommand: values["codex-command"],
|
|
444
|
+
judgeCommand: values["judge-command"],
|
|
445
|
+
allowMissingSentinel: values["allow-missing-sentinel"] ?? false,
|
|
446
|
+
skipJudge: values["skip-judge"] ?? false,
|
|
447
|
+
skipHtml: values["skip-html"] ?? false,
|
|
448
|
+
openHtml: values["open-html"] ?? false,
|
|
449
|
+
timeoutMs,
|
|
450
|
+
maxRetries,
|
|
451
|
+
};
|
|
452
|
+
}
|
|
453
|
+
function resolvePromptSelections(packageDir, options, judgeEnabled) {
|
|
454
|
+
return {
|
|
455
|
+
claude: resolvePromptSelection(packageDir, "review", options.reviewProfileId, options.claudePromptTemplate),
|
|
456
|
+
codex: resolvePromptSelection(packageDir, "review", options.reviewProfileId, options.codexPromptTemplate),
|
|
457
|
+
judge: judgeEnabled
|
|
458
|
+
? resolvePromptSelection(packageDir, "judge", options.judgeProfileId, options.judgePromptTemplate)
|
|
459
|
+
: null,
|
|
460
|
+
};
|
|
461
|
+
}
|
|
462
|
+
function createStageDefinitions(paths, promptSelections, commands, reviewTarget, reviewId, runId, reviewSchemaPath, judgeSchemaPath) {
|
|
463
|
+
const reviewerStages = [
|
|
464
|
+
{
|
|
465
|
+
name: "claude",
|
|
466
|
+
displayName: "Claude",
|
|
467
|
+
command: commands.claude,
|
|
468
|
+
stageDir: paths.claudeDir,
|
|
469
|
+
promptOutputName: "claude-review-export.md",
|
|
470
|
+
promptTemplatePath: promptSelections.claude.templatePath,
|
|
471
|
+
promptTemplateSource: promptSelections.claude.source,
|
|
472
|
+
requiredArtifacts: ["report.md", "findings.json"],
|
|
473
|
+
jsonArtifactName: "findings.json",
|
|
474
|
+
artifactSchema: reviewFindingsSchema,
|
|
475
|
+
doneSchema: reviewDoneSchema,
|
|
476
|
+
stageVars: {
|
|
477
|
+
TARGET: reviewTarget,
|
|
478
|
+
REVIEW_TARGET: reviewTarget,
|
|
479
|
+
REVIEW_ID: reviewId,
|
|
480
|
+
RUN_ID: runId,
|
|
481
|
+
REVIEW_DIR: paths.reviewDir,
|
|
482
|
+
RUN_DIR: paths.runDir,
|
|
483
|
+
ARTIFACT_DIR: paths.claudeDir,
|
|
484
|
+
SCHEMA_PATH: reviewSchemaPath,
|
|
485
|
+
REVIEWER_NAME: "Claude",
|
|
486
|
+
REVIEWER_NAME_LOWER: "claude",
|
|
487
|
+
},
|
|
488
|
+
},
|
|
489
|
+
{
|
|
490
|
+
name: "codex",
|
|
491
|
+
displayName: "Codex",
|
|
492
|
+
command: commands.codex,
|
|
493
|
+
stageDir: paths.codexDir,
|
|
494
|
+
promptOutputName: "codex-review-export.md",
|
|
495
|
+
promptTemplatePath: promptSelections.codex.templatePath,
|
|
496
|
+
promptTemplateSource: promptSelections.codex.source,
|
|
497
|
+
requiredArtifacts: ["report.md", "findings.json"],
|
|
498
|
+
jsonArtifactName: "findings.json",
|
|
499
|
+
artifactSchema: reviewFindingsSchema,
|
|
500
|
+
doneSchema: reviewDoneSchema,
|
|
501
|
+
stageVars: {
|
|
502
|
+
TARGET: reviewTarget,
|
|
503
|
+
REVIEW_TARGET: reviewTarget,
|
|
504
|
+
REVIEW_ID: reviewId,
|
|
505
|
+
RUN_ID: runId,
|
|
506
|
+
REVIEW_DIR: paths.reviewDir,
|
|
507
|
+
RUN_DIR: paths.runDir,
|
|
508
|
+
ARTIFACT_DIR: paths.codexDir,
|
|
509
|
+
SCHEMA_PATH: reviewSchemaPath,
|
|
510
|
+
REVIEWER_NAME: "Codex",
|
|
511
|
+
REVIEWER_NAME_LOWER: "codex",
|
|
512
|
+
},
|
|
513
|
+
},
|
|
514
|
+
];
|
|
515
|
+
if (!promptSelections.judge) {
|
|
516
|
+
return reviewerStages;
|
|
517
|
+
}
|
|
518
|
+
return [
|
|
519
|
+
...reviewerStages,
|
|
520
|
+
{
|
|
521
|
+
name: "judge",
|
|
522
|
+
displayName: "Judge",
|
|
523
|
+
command: commands.judge,
|
|
524
|
+
stageDir: paths.judgeDir,
|
|
525
|
+
promptOutputName: "judge.md",
|
|
526
|
+
promptTemplatePath: promptSelections.judge.templatePath,
|
|
527
|
+
promptTemplateSource: promptSelections.judge.source,
|
|
528
|
+
requiredArtifacts: ["summary.md", "verdict.json"],
|
|
529
|
+
jsonArtifactName: "verdict.json",
|
|
530
|
+
artifactSchema: judgeVerdictSchema,
|
|
531
|
+
doneSchema: judgeDoneSchema,
|
|
532
|
+
stageVars: {
|
|
533
|
+
TARGET: reviewTarget,
|
|
534
|
+
REVIEW_TARGET: reviewTarget,
|
|
535
|
+
REVIEW_ID: reviewId,
|
|
536
|
+
RUN_ID: runId,
|
|
537
|
+
REVIEW_DIR: paths.reviewDir,
|
|
538
|
+
RUN_DIR: paths.runDir,
|
|
539
|
+
ARTIFACT_DIR: paths.judgeDir,
|
|
540
|
+
SCHEMA_PATH: judgeSchemaPath,
|
|
541
|
+
},
|
|
542
|
+
},
|
|
543
|
+
];
|
|
544
|
+
}
|
|
545
|
+
function writeRunMetadata(preparedRun) {
|
|
546
|
+
const { options, cwd, packageDir, reviewTarget, reviewId, runId, judgeEnabled, paths, promptSelections, } = preparedRun;
|
|
547
|
+
writeFileSync(resolve(paths.runDir, "run.json"), `${JSON.stringify({
|
|
548
|
+
review_id: reviewId,
|
|
549
|
+
run_id: runId,
|
|
550
|
+
review_target: reviewTarget,
|
|
551
|
+
created_at: nowIso(),
|
|
552
|
+
cwd,
|
|
553
|
+
skill_dir: packageDir,
|
|
554
|
+
review_dir: paths.reviewDir,
|
|
555
|
+
run_dir: paths.runDir,
|
|
556
|
+
review_id_source: options.reviewId ? "explicit" : "derived",
|
|
557
|
+
review_profile: options.reviewProfileId,
|
|
558
|
+
judge_profile: options.judgeProfileId,
|
|
559
|
+
prompt_templates: {
|
|
560
|
+
claude: {
|
|
561
|
+
path: promptSelections.claude.templatePath,
|
|
562
|
+
source: promptSelections.claude.source,
|
|
563
|
+
},
|
|
564
|
+
codex: {
|
|
565
|
+
path: promptSelections.codex.templatePath,
|
|
566
|
+
source: promptSelections.codex.source,
|
|
567
|
+
},
|
|
568
|
+
judge: {
|
|
569
|
+
path: promptSelections.judge?.templatePath ?? null,
|
|
570
|
+
source: promptSelections.judge?.source ?? null,
|
|
571
|
+
},
|
|
572
|
+
},
|
|
573
|
+
command_templates: {
|
|
574
|
+
claude: options.claudeCommand ?? null,
|
|
575
|
+
codex: options.codexCommand ?? null,
|
|
576
|
+
judge: judgeEnabled ? options.judgeCommand ?? null : null,
|
|
577
|
+
},
|
|
578
|
+
}, null, 2)}\n`);
|
|
579
|
+
}
|
|
580
|
+
function prepareRun(options) {
|
|
581
|
+
const moduleDir = resolve(fileURLToPath(new URL(".", import.meta.url)));
|
|
582
|
+
const packageDir = resolve(moduleDir, "..");
|
|
583
|
+
const cwd = process.cwd();
|
|
584
|
+
const reviewTarget = normalizeReviewTarget(options.target);
|
|
585
|
+
const reviewId = options.reviewId ?? deriveReviewId(cwd, reviewTarget);
|
|
586
|
+
const reviewIdError = validateReviewId(reviewId);
|
|
587
|
+
if (reviewIdError) {
|
|
588
|
+
console.error(`Invalid --review-id "${reviewId}": ${reviewIdError}`);
|
|
589
|
+
process.exitCode = 1;
|
|
590
|
+
return null;
|
|
591
|
+
}
|
|
592
|
+
const runId = createRunId();
|
|
593
|
+
const paths = buildReviewPaths(cwd, reviewId, runId, options.runDir);
|
|
594
|
+
const judgeEnabled = !options.skipJudge && Boolean(options.judgeCommand);
|
|
595
|
+
let promptSelections;
|
|
596
|
+
try {
|
|
597
|
+
promptSelections = resolvePromptSelections(packageDir, options, judgeEnabled);
|
|
598
|
+
}
|
|
599
|
+
catch (error) {
|
|
600
|
+
console.error(error instanceof Error ? error.message : String(error));
|
|
601
|
+
process.exitCode = 1;
|
|
602
|
+
return null;
|
|
603
|
+
}
|
|
604
|
+
for (const dir of [paths.reviewDir, paths.runDir, paths.claudeDir, paths.codexDir, paths.judgeDir]) {
|
|
605
|
+
mkdirSync(dir, { recursive: true });
|
|
606
|
+
}
|
|
607
|
+
const reviewSchemaPath = resolve(packageDir, "schemas", "review-findings.schema.json");
|
|
608
|
+
const judgeSchemaPath = resolve(packageDir, "schemas", "judge-verdict.schema.json");
|
|
609
|
+
const commandEnv = {
|
|
610
|
+
CWD: cwd,
|
|
611
|
+
SKILL_DIR: packageDir,
|
|
612
|
+
REVIEW_ID: reviewId,
|
|
613
|
+
RUN_ID: runId,
|
|
614
|
+
REVIEW_DIR: paths.reviewDir,
|
|
615
|
+
RUN_DIR: paths.runDir,
|
|
616
|
+
CLAUDE_DIR: paths.claudeDir,
|
|
617
|
+
CODEX_DIR: paths.codexDir,
|
|
618
|
+
JUDGE_DIR: paths.judgeDir,
|
|
619
|
+
REVIEW_SCHEMA: reviewSchemaPath,
|
|
620
|
+
JUDGE_SCHEMA: judgeSchemaPath,
|
|
621
|
+
};
|
|
622
|
+
const rawCommands = {
|
|
623
|
+
claude: options.claudeCommand,
|
|
624
|
+
codex: options.codexCommand,
|
|
625
|
+
judge: judgeEnabled ? options.judgeCommand : undefined,
|
|
626
|
+
};
|
|
627
|
+
const stageDefinitions = createStageDefinitions(paths, promptSelections, rawCommands, reviewTarget, reviewId, runId, reviewSchemaPath, judgeSchemaPath);
|
|
628
|
+
for (const stage of stageDefinitions) {
|
|
629
|
+
renderTemplate(stage.promptTemplatePath, stage.stageVars, resolve(stage.stageDir, stage.promptOutputName));
|
|
630
|
+
}
|
|
631
|
+
writeRunMetadata({
|
|
632
|
+
options,
|
|
633
|
+
cwd,
|
|
634
|
+
packageDir,
|
|
635
|
+
reviewTarget,
|
|
636
|
+
reviewId,
|
|
637
|
+
runId,
|
|
638
|
+
judgeEnabled,
|
|
639
|
+
requireSentinel: !options.allowMissingSentinel,
|
|
640
|
+
paths,
|
|
641
|
+
promptSelections,
|
|
642
|
+
commandEnv,
|
|
643
|
+
stageDefinitions,
|
|
644
|
+
});
|
|
645
|
+
for (const command of Object.values(rawCommands)) {
|
|
646
|
+
if (command) {
|
|
647
|
+
assertBinaryExists(command, cwd);
|
|
648
|
+
}
|
|
649
|
+
}
|
|
650
|
+
return {
|
|
651
|
+
options,
|
|
652
|
+
cwd,
|
|
653
|
+
packageDir,
|
|
654
|
+
reviewTarget,
|
|
655
|
+
reviewId,
|
|
656
|
+
runId,
|
|
657
|
+
judgeEnabled,
|
|
658
|
+
requireSentinel: !options.allowMissingSentinel,
|
|
659
|
+
paths,
|
|
660
|
+
promptSelections,
|
|
661
|
+
commandEnv,
|
|
662
|
+
stageDefinitions,
|
|
663
|
+
};
|
|
664
|
+
}
|
|
665
|
+
async function runReviewerStages(preparedRun) {
|
|
666
|
+
const reviewerStages = preparedRun.stageDefinitions.filter((s) => s.name !== "judge");
|
|
667
|
+
const results = await Promise.all(reviewerStages.map((stage) => runStage(stage, preparedRun.cwd, preparedRun.requireSentinel, preparedRun.options.timeoutMs, preparedRun.options.maxRetries, preparedRun.reviewId, preparedRun.runId, preparedRun.commandEnv)));
|
|
668
|
+
closeInteractionQueue();
|
|
669
|
+
const reviewerResults = results.filter((result) => result !== null);
|
|
670
|
+
const successfulReviewerResults = reviewerResults.filter((result) => result.success);
|
|
671
|
+
return {
|
|
672
|
+
reviewerResults,
|
|
673
|
+
successfulReviewerResults,
|
|
674
|
+
reviewersOk: reviewerResults.length > 0 && successfulReviewerResults.length === reviewerResults.length,
|
|
675
|
+
reviewersPartial: successfulReviewerResults.length > 0 && successfulReviewerResults.length < reviewerResults.length,
|
|
676
|
+
};
|
|
677
|
+
}
|
|
678
|
+
async function runJudgeStage(preparedRun, reviewerExecution) {
|
|
679
|
+
if (!preparedRun.judgeEnabled) {
|
|
680
|
+
return null;
|
|
681
|
+
}
|
|
682
|
+
if (reviewerExecution.reviewerResults.length === 0) {
|
|
683
|
+
console.error("Judge stage requires at least one configured reviewer command.");
|
|
684
|
+
process.exitCode = 1;
|
|
685
|
+
return null;
|
|
686
|
+
}
|
|
687
|
+
if (reviewerExecution.successfulReviewerResults.length === 0) {
|
|
688
|
+
return null;
|
|
689
|
+
}
|
|
690
|
+
const judgeStage = preparedRun.stageDefinitions.find((stage) => stage.name === "judge");
|
|
691
|
+
if (!judgeStage) {
|
|
692
|
+
return null;
|
|
693
|
+
}
|
|
694
|
+
return runStage(judgeStage, preparedRun.cwd, preparedRun.requireSentinel, preparedRun.options.timeoutMs, preparedRun.options.maxRetries, preparedRun.reviewId, preparedRun.runId, preparedRun.commandEnv);
|
|
695
|
+
}
|
|
696
|
+
function finalizeRun(preparedRun, reviewerExecution, judgeResult) {
|
|
697
|
+
if (!preparedRun.options.skipHtml) {
|
|
698
|
+
renderRunDir(preparedRun.paths.runDir);
|
|
699
|
+
}
|
|
700
|
+
if (preparedRun.options.openHtml && !preparedRun.options.skipHtml) {
|
|
701
|
+
const htmlPath = resolve(preparedRun.paths.runDir, "index.html");
|
|
702
|
+
if (existsSync(htmlPath)) {
|
|
703
|
+
spawn("open", [htmlPath], { stdio: "ignore", detached: true }).unref();
|
|
704
|
+
}
|
|
705
|
+
}
|
|
706
|
+
const runUsable = reviewerExecution.successfulReviewerResults.length > 0
|
|
707
|
+
&& (!preparedRun.judgeEnabled || judgeResult?.success === true);
|
|
708
|
+
if (runUsable && isReviewScopedRunDir(preparedRun.paths)) {
|
|
709
|
+
writeLatestRunMarker(preparedRun.paths.reviewDir, preparedRun.paths.runDir, preparedRun.reviewId, preparedRun.runId);
|
|
710
|
+
}
|
|
711
|
+
console.log(JSON.stringify({
|
|
712
|
+
review_id: preparedRun.reviewId,
|
|
713
|
+
run_id: preparedRun.runId,
|
|
714
|
+
review_dir: preparedRun.paths.reviewDir,
|
|
715
|
+
run_dir: preparedRun.paths.runDir,
|
|
716
|
+
reviewers_ok: reviewerExecution.reviewersOk,
|
|
717
|
+
reviewers_partial: reviewerExecution.reviewersPartial,
|
|
718
|
+
reviewers: reviewerExecution.reviewerResults.map((result) => ({
|
|
719
|
+
name: result.name,
|
|
720
|
+
success: result.success,
|
|
721
|
+
timed_out: result.timed_out,
|
|
722
|
+
exit_code: result.exit_code,
|
|
723
|
+
attempts: result.attempts,
|
|
724
|
+
failure_reason: result.failure_reason ?? null,
|
|
725
|
+
validation_errors: result.validation_errors ?? [],
|
|
726
|
+
missing_artifacts: result.missing_artifacts ?? [],
|
|
727
|
+
})),
|
|
728
|
+
judge_ran: judgeResult !== null,
|
|
729
|
+
judge_ok: judgeResult?.success ?? false,
|
|
730
|
+
}, null, 2));
|
|
731
|
+
process.exitCode = runUsable ? 0 : 1;
|
|
732
|
+
}
|
|
733
|
+
export function printHelp(commandName = "review-council") {
|
|
734
|
+
console.error(`usage: ${commandName} --target <target> [options]
|
|
735
|
+
|
|
736
|
+
options:
|
|
737
|
+
--target <target> Review target label
|
|
738
|
+
--review-id <id> Stable review identifier
|
|
739
|
+
--run-dir <dir> Output directory for this run
|
|
740
|
+
--review-profile <id> Reviewer prompt profile (default: default)
|
|
741
|
+
--judge-profile <id> Judge prompt profile (default: default)
|
|
742
|
+
--claude-prompt-template <path> Override Claude reviewer prompt template
|
|
743
|
+
--codex-prompt-template <path> Override Codex reviewer prompt template
|
|
744
|
+
--judge-prompt-template <path> Override judge prompt template
|
|
745
|
+
--claude-command <command> Shell command to launch Claude reviewer
|
|
746
|
+
--codex-command <command> Shell command to launch Codex reviewer
|
|
747
|
+
--judge-command <command> Shell command to launch the judge stage
|
|
748
|
+
|
|
749
|
+
environment variables available in commands:
|
|
750
|
+
$CWD, $SKILL_DIR, $REVIEW_ID, $RUN_ID, $REVIEW_DIR, $RUN_DIR,
|
|
751
|
+
$CLAUDE_DIR, $CODEX_DIR, $JUDGE_DIR, $REVIEW_SCHEMA, $JUDGE_SCHEMA
|
|
752
|
+
--allow-missing-sentinel Treat exit code 0 as success without done.json
|
|
753
|
+
--skip-judge Skip the judge stage
|
|
754
|
+
--skip-html Skip HTML rendering
|
|
755
|
+
--open-html Open index.html after rendering (macOS)
|
|
756
|
+
--timeout <ms> Stage timeout in ms (default: 300000)
|
|
757
|
+
--retries <n> Max retries per stage on failure (default: 2)
|
|
758
|
+
--help Show this help output`);
|
|
759
|
+
}
|
|
760
|
+
export async function main(args = process.argv.slice(2)) {
|
|
761
|
+
const options = parseCliOptions(args);
|
|
762
|
+
if (!options) {
|
|
763
|
+
return;
|
|
764
|
+
}
|
|
765
|
+
const preparedRun = prepareRun(options);
|
|
766
|
+
if (!preparedRun) {
|
|
767
|
+
return;
|
|
768
|
+
}
|
|
769
|
+
const reviewerExecution = await runReviewerStages(preparedRun);
|
|
770
|
+
const judgeResult = await runJudgeStage(preparedRun, reviewerExecution);
|
|
771
|
+
finalizeRun(preparedRun, reviewerExecution, judgeResult);
|
|
772
|
+
}
|