@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,307 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { existsSync, readFileSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { resolve } from "node:path";
|
|
4
|
+
import { fileURLToPath } from "node:url";
|
|
5
|
+
import MarkdownIt from "markdown-it";
|
|
6
|
+
function htmlEscape(value) {
|
|
7
|
+
return value
|
|
8
|
+
.replaceAll("&", "&")
|
|
9
|
+
.replaceAll("<", "<")
|
|
10
|
+
.replaceAll(">", ">")
|
|
11
|
+
.replaceAll('"', """)
|
|
12
|
+
.replaceAll("'", "'");
|
|
13
|
+
}
|
|
14
|
+
const markdownRenderer = new MarkdownIt({
|
|
15
|
+
html: false,
|
|
16
|
+
linkify: true,
|
|
17
|
+
typographer: false,
|
|
18
|
+
});
|
|
19
|
+
function renderMarkdown(markdown) {
|
|
20
|
+
if (!markdown.trim()) {
|
|
21
|
+
return '<p class="empty">No content yet.</p>';
|
|
22
|
+
}
|
|
23
|
+
return markdownRenderer.render(markdown);
|
|
24
|
+
}
|
|
25
|
+
function loadJsonWithStatus(path) {
|
|
26
|
+
if (!existsSync(path)) {
|
|
27
|
+
return { data: {}, status: "missing" };
|
|
28
|
+
}
|
|
29
|
+
try {
|
|
30
|
+
return { data: JSON.parse(readFileSync(path, "utf8")), status: "ok" };
|
|
31
|
+
}
|
|
32
|
+
catch {
|
|
33
|
+
return { data: {}, status: "malformed" };
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
function loadText(path) {
|
|
37
|
+
if (!existsSync(path)) {
|
|
38
|
+
return "";
|
|
39
|
+
}
|
|
40
|
+
return readFileSync(path, "utf8");
|
|
41
|
+
}
|
|
42
|
+
function flattenFindings(document, reviewer) {
|
|
43
|
+
const findings = document.findings;
|
|
44
|
+
if (!Array.isArray(findings)) {
|
|
45
|
+
return [];
|
|
46
|
+
}
|
|
47
|
+
return findings
|
|
48
|
+
.filter((item) => typeof item === "object" && item !== null && !Array.isArray(item))
|
|
49
|
+
.map((item) => ({ ...item, reviewer }));
|
|
50
|
+
}
|
|
51
|
+
function stageStatusRow(name, status) {
|
|
52
|
+
const success = status.success === true;
|
|
53
|
+
const isEmpty = Object.keys(status).length === 0;
|
|
54
|
+
const state = isEmpty ? "pending" : success ? "success" : "failed";
|
|
55
|
+
const label = isEmpty ? "pending" : success ? "complete" : "failed";
|
|
56
|
+
const details = [];
|
|
57
|
+
if (!isEmpty) {
|
|
58
|
+
if (typeof status.exit_code === "number") {
|
|
59
|
+
details.push(`exit ${status.exit_code}`);
|
|
60
|
+
}
|
|
61
|
+
if (status.timed_out === true) {
|
|
62
|
+
details.push("timed out");
|
|
63
|
+
}
|
|
64
|
+
if (typeof status.attempts === "number" && status.attempts > 1) {
|
|
65
|
+
details.push(`${status.attempts} attempts`);
|
|
66
|
+
}
|
|
67
|
+
if (Array.isArray(status.validation_errors) && status.validation_errors.length > 0) {
|
|
68
|
+
details.push(`${status.validation_errors.length} validation error(s)`);
|
|
69
|
+
}
|
|
70
|
+
if (Array.isArray(status.missing_artifacts) && status.missing_artifacts.length > 0) {
|
|
71
|
+
details.push(`${status.missing_artifacts.length} missing artifact(s)`);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
const detailSpan = details.length > 0
|
|
75
|
+
? `<span class="status-details">${htmlEscape(details.join(" · "))}</span>`
|
|
76
|
+
: "";
|
|
77
|
+
return [
|
|
78
|
+
`<div class="status-row status-${htmlEscape(state)}">`,
|
|
79
|
+
`<strong>${htmlEscape(name)}</strong>`,
|
|
80
|
+
`<span>${htmlEscape(label)}${detailSpan}</span>`,
|
|
81
|
+
"</div>",
|
|
82
|
+
].join("");
|
|
83
|
+
}
|
|
84
|
+
function stderrExcerpt(stageDir, maxLines = 20) {
|
|
85
|
+
const stderrPath = resolve(stageDir, "stderr.log");
|
|
86
|
+
const text = loadText(stderrPath);
|
|
87
|
+
if (!text.trim())
|
|
88
|
+
return "";
|
|
89
|
+
const lines = text.split("\n");
|
|
90
|
+
return lines.slice(-maxLines).join("\n");
|
|
91
|
+
}
|
|
92
|
+
function buildDiagnostics(runDir, statuses) {
|
|
93
|
+
const blocks = [];
|
|
94
|
+
for (const [stage, status] of Object.entries(statuses)) {
|
|
95
|
+
if (Object.keys(status).length === 0 || status.success === true)
|
|
96
|
+
continue;
|
|
97
|
+
const parts = [];
|
|
98
|
+
parts.push('<div class="diagnostic-block">');
|
|
99
|
+
parts.push(`<h3>${htmlEscape(stage)}</h3>`);
|
|
100
|
+
if (Array.isArray(status.validation_errors)) {
|
|
101
|
+
const errors = status.validation_errors;
|
|
102
|
+
parts.push("<p><strong>Validation errors:</strong></p><ul>");
|
|
103
|
+
for (const error of errors) {
|
|
104
|
+
const location = error.path ? `${error.path}: ` : "";
|
|
105
|
+
parts.push(`<li>${htmlEscape(`${location}${error.message ?? "unknown"}`)}</li>`);
|
|
106
|
+
}
|
|
107
|
+
parts.push("</ul>");
|
|
108
|
+
}
|
|
109
|
+
if (Array.isArray(status.missing_artifacts) && status.missing_artifacts.length > 0) {
|
|
110
|
+
const missingArtifacts = status.missing_artifacts;
|
|
111
|
+
parts.push("<p><strong>Missing artifacts:</strong></p><ul>");
|
|
112
|
+
for (const artifact of missingArtifacts) {
|
|
113
|
+
parts.push(`<li>${htmlEscape(artifact)}</li>`);
|
|
114
|
+
}
|
|
115
|
+
parts.push("</ul>");
|
|
116
|
+
}
|
|
117
|
+
const stageDir = resolve(runDir, stage);
|
|
118
|
+
const excerpt = stderrExcerpt(stageDir);
|
|
119
|
+
if (excerpt) {
|
|
120
|
+
parts.push("<p><strong>stderr (last 20 lines):</strong></p>");
|
|
121
|
+
parts.push(`<pre class="stderr-excerpt">${htmlEscape(excerpt)}</pre>`);
|
|
122
|
+
}
|
|
123
|
+
const stdoutLog = typeof status.stdout_log === "string" ? status.stdout_log : "";
|
|
124
|
+
const stderrLog = typeof status.stderr_log === "string" ? status.stderr_log : "";
|
|
125
|
+
if (stdoutLog || stderrLog) {
|
|
126
|
+
parts.push('<div class="log-paths">');
|
|
127
|
+
if (stdoutLog)
|
|
128
|
+
parts.push(`<code>${htmlEscape(stdoutLog)}</code>`);
|
|
129
|
+
if (stderrLog)
|
|
130
|
+
parts.push(`<code>${htmlEscape(stderrLog)}</code>`);
|
|
131
|
+
parts.push("</div>");
|
|
132
|
+
}
|
|
133
|
+
parts.push("</div>");
|
|
134
|
+
blocks.push(parts.join(""));
|
|
135
|
+
}
|
|
136
|
+
return blocks.join("");
|
|
137
|
+
}
|
|
138
|
+
function filesLabel(files) {
|
|
139
|
+
if (files.length === 0) {
|
|
140
|
+
return "-";
|
|
141
|
+
}
|
|
142
|
+
return files
|
|
143
|
+
.map((item) => {
|
|
144
|
+
if (!item.path)
|
|
145
|
+
return "?";
|
|
146
|
+
return item.line ? `${item.path}:${item.line}` : item.path;
|
|
147
|
+
})
|
|
148
|
+
.join(", ");
|
|
149
|
+
}
|
|
150
|
+
function candidateRow(row) {
|
|
151
|
+
const severity = String(row.severity ?? "-");
|
|
152
|
+
return [
|
|
153
|
+
"<tr>",
|
|
154
|
+
`<td>${htmlEscape(String(row.reviewer ?? "-"))}</td>`,
|
|
155
|
+
`<td><span class="severity ${htmlEscape(severity.toLowerCase())}">${htmlEscape(severity.toUpperCase())}</span></td>`,
|
|
156
|
+
`<td>${htmlEscape(String(row.title ?? "-"))}</td>`,
|
|
157
|
+
`<td>${htmlEscape(String(row.confidence ?? "-"))}</td>`,
|
|
158
|
+
`<td>${htmlEscape(filesLabel(row.files))}</td>`,
|
|
159
|
+
"</tr>",
|
|
160
|
+
].join("");
|
|
161
|
+
}
|
|
162
|
+
function verdictRows(verdict) {
|
|
163
|
+
const rows = [];
|
|
164
|
+
const groups = [
|
|
165
|
+
["confirmed", verdict.confirmed_findings],
|
|
166
|
+
["contested", verdict.contested_findings],
|
|
167
|
+
["rejected", verdict.rejected_findings],
|
|
168
|
+
];
|
|
169
|
+
for (const [status, value] of groups) {
|
|
170
|
+
if (!Array.isArray(value))
|
|
171
|
+
continue;
|
|
172
|
+
for (const item of value) {
|
|
173
|
+
if (typeof item !== "object" || item === null || Array.isArray(item)) {
|
|
174
|
+
continue;
|
|
175
|
+
}
|
|
176
|
+
const verdictItem = item;
|
|
177
|
+
rows.push([
|
|
178
|
+
"<tr>",
|
|
179
|
+
`<td>${htmlEscape(status)}</td>`,
|
|
180
|
+
`<td>${htmlEscape(String(verdictItem.title ?? "-"))}</td>`,
|
|
181
|
+
`<td>${htmlEscape(String(verdictItem.reason ?? "-"))}</td>`,
|
|
182
|
+
`<td>${htmlEscape(String(verdictItem.final_priority ?? "-"))}</td>`,
|
|
183
|
+
"</tr>",
|
|
184
|
+
].join(""));
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
return rows.length > 0
|
|
188
|
+
? rows.join("")
|
|
189
|
+
: '<tr><td colspan="4" class="empty">No judge verdict rows yet.</td></tr>';
|
|
190
|
+
}
|
|
191
|
+
function chips(bundle) {
|
|
192
|
+
const verdict = bundle.judge_verdict;
|
|
193
|
+
const confirmed = Array.isArray(verdict.confirmed_findings) ? verdict.confirmed_findings.length : 0;
|
|
194
|
+
const contested = Array.isArray(verdict.contested_findings) ? verdict.contested_findings.length : 0;
|
|
195
|
+
const rejected = Array.isArray(verdict.rejected_findings) ? verdict.rejected_findings.length : 0;
|
|
196
|
+
return [
|
|
197
|
+
`Overall: ${String(verdict.overall_verdict ?? "incomplete")}`,
|
|
198
|
+
`Candidate findings: ${bundle.candidate_findings.length}`,
|
|
199
|
+
`Confirmed: ${confirmed}`,
|
|
200
|
+
`Contested: ${contested}`,
|
|
201
|
+
`Rejected: ${rejected}`,
|
|
202
|
+
]
|
|
203
|
+
.map((item) => `<span class="chip">${htmlEscape(item)}</span>`)
|
|
204
|
+
.join("");
|
|
205
|
+
}
|
|
206
|
+
export function buildBundle(runDir) {
|
|
207
|
+
const resolvedRunDir = resolve(runDir);
|
|
208
|
+
const run = loadJsonWithStatus(resolve(resolvedRunDir, "run.json"));
|
|
209
|
+
const claudeDoc = loadJsonWithStatus(resolve(resolvedRunDir, "claude", "findings.json"));
|
|
210
|
+
const codexDoc = loadJsonWithStatus(resolve(resolvedRunDir, "codex", "findings.json"));
|
|
211
|
+
const judgeDoc = loadJsonWithStatus(resolve(resolvedRunDir, "judge", "verdict.json"));
|
|
212
|
+
const reviewId = typeof run.data.review_id === "string" ? run.data.review_id : "";
|
|
213
|
+
const runId = typeof run.data.run_id === "string" ? run.data.run_id : "";
|
|
214
|
+
const reviewTarget = typeof run.data.review_target === "string"
|
|
215
|
+
? run.data.review_target
|
|
216
|
+
: (typeof run.data.target === "string" ? run.data.target : "-");
|
|
217
|
+
return {
|
|
218
|
+
review_id: reviewId,
|
|
219
|
+
run_id: runId,
|
|
220
|
+
review_target: reviewTarget,
|
|
221
|
+
run: run.data,
|
|
222
|
+
candidate_findings: [
|
|
223
|
+
...flattenFindings(claudeDoc.data, "claude"),
|
|
224
|
+
...flattenFindings(codexDoc.data, "codex"),
|
|
225
|
+
],
|
|
226
|
+
judge_verdict: judgeDoc.data,
|
|
227
|
+
status: {
|
|
228
|
+
claude: loadJsonWithStatus(resolve(resolvedRunDir, "claude", "status.json")).data,
|
|
229
|
+
codex: loadJsonWithStatus(resolve(resolvedRunDir, "codex", "status.json")).data,
|
|
230
|
+
judge: loadJsonWithStatus(resolve(resolvedRunDir, "judge", "status.json")).data,
|
|
231
|
+
},
|
|
232
|
+
reports: {
|
|
233
|
+
claude: loadText(resolve(resolvedRunDir, "claude", "report.md")),
|
|
234
|
+
codex: loadText(resolve(resolvedRunDir, "codex", "report.md")),
|
|
235
|
+
judge: loadText(resolve(resolvedRunDir, "judge", "summary.md")),
|
|
236
|
+
},
|
|
237
|
+
artifact_status: {
|
|
238
|
+
claude: claudeDoc.status,
|
|
239
|
+
codex: codexDoc.status,
|
|
240
|
+
judge: judgeDoc.status,
|
|
241
|
+
},
|
|
242
|
+
};
|
|
243
|
+
}
|
|
244
|
+
export function renderRunDir(runDir, templatePath) {
|
|
245
|
+
const resolvedRunDir = resolve(runDir);
|
|
246
|
+
const moduleDir = resolve(fileURLToPath(new URL(".", import.meta.url)));
|
|
247
|
+
const packageDir = resolve(moduleDir, "..");
|
|
248
|
+
const resolvedTemplatePath = templatePath
|
|
249
|
+
? resolve(templatePath)
|
|
250
|
+
: resolve(packageDir, "templates", "report.html");
|
|
251
|
+
const bundle = buildBundle(resolvedRunDir);
|
|
252
|
+
const template = readFileSync(resolvedTemplatePath, "utf8");
|
|
253
|
+
const target = bundle.review_target;
|
|
254
|
+
const candidateRows = bundle.candidate_findings.length > 0
|
|
255
|
+
? bundle.candidate_findings.map(candidateRow).join("")
|
|
256
|
+
: '<tr><td colspan="5" class="empty">No candidate findings yet.</td></tr>';
|
|
257
|
+
const statusRows = [
|
|
258
|
+
stageStatusRow("Claude", bundle.status.claude),
|
|
259
|
+
stageStatusRow("Codex", bundle.status.codex),
|
|
260
|
+
stageStatusRow("Judge", bundle.status.judge),
|
|
261
|
+
].join("");
|
|
262
|
+
const diagnosticsContent = buildDiagnostics(resolvedRunDir, {
|
|
263
|
+
claude: bundle.status.claude,
|
|
264
|
+
codex: bundle.status.codex,
|
|
265
|
+
judge: bundle.status.judge,
|
|
266
|
+
});
|
|
267
|
+
const hasDiagnostics = diagnosticsContent.length > 0;
|
|
268
|
+
const replacements = {
|
|
269
|
+
"__TITLE__": "Review Council Report",
|
|
270
|
+
"__HEADING__": "Review Council",
|
|
271
|
+
"__META__": htmlEscape(`Target: ${target} · Review ID: ${bundle.review_id || "-"} · Run ID: ${bundle.run_id || "-"}`),
|
|
272
|
+
"__CHIPS__": chips(bundle),
|
|
273
|
+
"__JUDGE_SUMMARY__": renderMarkdown(bundle.reports.judge || "Judge output not available yet."),
|
|
274
|
+
"__STATUS_ROWS__": statusRows,
|
|
275
|
+
"__DIAGNOSTICS_DISPLAY__": hasDiagnostics ? "block" : "none",
|
|
276
|
+
"__DIAGNOSTICS_CONTENT__": diagnosticsContent,
|
|
277
|
+
"__CANDIDATE_ROWS__": candidateRows,
|
|
278
|
+
"__VERDICT_ROWS__": verdictRows(bundle.judge_verdict),
|
|
279
|
+
"__CLAUDE_REPORT__": renderMarkdown(bundle.reports.claude || "Claude report not available yet."),
|
|
280
|
+
"__CODEX_REPORT__": renderMarkdown(bundle.reports.codex || "Codex report not available yet."),
|
|
281
|
+
};
|
|
282
|
+
let htmlOutput = template;
|
|
283
|
+
for (const [needle, value] of Object.entries(replacements)) {
|
|
284
|
+
htmlOutput = htmlOutput.replaceAll(needle, value);
|
|
285
|
+
}
|
|
286
|
+
writeFileSync(resolve(resolvedRunDir, "bundle.json"), `${JSON.stringify(bundle, null, 2)}\n`);
|
|
287
|
+
writeFileSync(resolve(resolvedRunDir, "index.html"), htmlOutput);
|
|
288
|
+
}
|
|
289
|
+
export function main(args = process.argv.slice(2)) {
|
|
290
|
+
const [runDir, ...rest] = args;
|
|
291
|
+
if (!runDir || runDir === "--help" || runDir === "-h") {
|
|
292
|
+
console.log("usage: render-review-html [run_dir] [--template path]");
|
|
293
|
+
process.exit(runDir ? 0 : 1);
|
|
294
|
+
}
|
|
295
|
+
let templatePath;
|
|
296
|
+
for (let index = 0; index < rest.length; index += 1) {
|
|
297
|
+
if (rest[index] === "--template") {
|
|
298
|
+
templatePath = rest[index + 1];
|
|
299
|
+
index += 1;
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
renderRunDir(runDir, templatePath);
|
|
303
|
+
}
|
|
304
|
+
const currentFile = fileURLToPath(import.meta.url);
|
|
305
|
+
if (process.argv[1] && resolve(process.argv[1]) === currentFile) {
|
|
306
|
+
main();
|
|
307
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
2
|
+
import { realpathSync } from "node:fs";
|
|
3
|
+
import { relative, resolve, sep } from "node:path";
|
|
4
|
+
export const REVIEW_ID_PATTERN = /^[a-z0-9](?:[a-z0-9._-]{0,126}[a-z0-9])?$/;
|
|
5
|
+
function realpathOrResolve(path) {
|
|
6
|
+
try {
|
|
7
|
+
return realpathSync(path);
|
|
8
|
+
}
|
|
9
|
+
catch {
|
|
10
|
+
return resolve(path);
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
function normalizeWhitespace(value) {
|
|
14
|
+
return value.trim().replaceAll(/\s+/g, " ");
|
|
15
|
+
}
|
|
16
|
+
function slugify(value, maxLength = 48) {
|
|
17
|
+
const slug = value
|
|
18
|
+
.toLowerCase()
|
|
19
|
+
.replaceAll(/[^a-z0-9]+/g, "-")
|
|
20
|
+
.replaceAll(/^-+|-+$/g, "")
|
|
21
|
+
.replaceAll(/-{2,}/g, "-");
|
|
22
|
+
if (!slug) {
|
|
23
|
+
return "review";
|
|
24
|
+
}
|
|
25
|
+
return slug.slice(0, maxLength).replaceAll(/-+$/g, "") || "review";
|
|
26
|
+
}
|
|
27
|
+
function formatTimestamp(date) {
|
|
28
|
+
const iso = date.toISOString();
|
|
29
|
+
const [day, time] = iso.split("T");
|
|
30
|
+
const datePart = day.replaceAll("-", "");
|
|
31
|
+
const timePart = time.replace("Z", "").replaceAll(":", "").replace(".", "");
|
|
32
|
+
return `${datePart}-${timePart}`;
|
|
33
|
+
}
|
|
34
|
+
export function normalizeReviewTarget(target) {
|
|
35
|
+
return normalizeWhitespace(target);
|
|
36
|
+
}
|
|
37
|
+
export function deriveReviewId(cwd, target) {
|
|
38
|
+
const normalizedRoot = realpathOrResolve(cwd);
|
|
39
|
+
const normalizedTarget = normalizeReviewTarget(target).toLowerCase();
|
|
40
|
+
const hash = createHash("sha1")
|
|
41
|
+
.update(normalizedRoot)
|
|
42
|
+
.update("\n")
|
|
43
|
+
.update(normalizedTarget)
|
|
44
|
+
.digest("hex")
|
|
45
|
+
.slice(0, 12);
|
|
46
|
+
const targetSlug = slugify(normalizedTarget);
|
|
47
|
+
return `${targetSlug}-${hash}`;
|
|
48
|
+
}
|
|
49
|
+
export function validateReviewId(reviewId) {
|
|
50
|
+
if (!REVIEW_ID_PATTERN.test(reviewId)) {
|
|
51
|
+
return "Review IDs must match /^[a-z0-9](?:[a-z0-9._-]{0,126}[a-z0-9])?$/";
|
|
52
|
+
}
|
|
53
|
+
return null;
|
|
54
|
+
}
|
|
55
|
+
export function createRunId(date = new Date(), uuid = randomUUID()) {
|
|
56
|
+
const suffix = uuid.replaceAll("-", "").slice(0, 8).toLowerCase();
|
|
57
|
+
return `${formatTimestamp(date)}-${suffix}`;
|
|
58
|
+
}
|
|
59
|
+
export function buildReviewPaths(cwd, reviewId, runId, explicitRunDir) {
|
|
60
|
+
const rootDir = resolve(cwd, "docs", "reviews");
|
|
61
|
+
const reviewDir = resolve(rootDir, reviewId);
|
|
62
|
+
const runsDir = resolve(reviewDir, "runs");
|
|
63
|
+
const runDir = explicitRunDir ? resolve(explicitRunDir) : resolve(runsDir, runId);
|
|
64
|
+
return {
|
|
65
|
+
rootDir,
|
|
66
|
+
reviewDir,
|
|
67
|
+
runsDir,
|
|
68
|
+
runDir,
|
|
69
|
+
claudeDir: resolve(runDir, "claude"),
|
|
70
|
+
codexDir: resolve(runDir, "codex"),
|
|
71
|
+
judgeDir: resolve(runDir, "judge"),
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
export function isReviewScopedRunDir(paths) {
|
|
75
|
+
const relativePath = relative(paths.runsDir, paths.runDir);
|
|
76
|
+
return relativePath !== "" && relativePath !== ".." && !relativePath.startsWith(`..${sep}`) && !relativePath.includes(`${sep}..${sep}`);
|
|
77
|
+
}
|
package/dist/schemas.js
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
const fileRef = z.object({
|
|
3
|
+
path: z.string(),
|
|
4
|
+
line: z.number().int().min(1).optional(),
|
|
5
|
+
}).strict();
|
|
6
|
+
const finding = z.object({
|
|
7
|
+
id: z.string(),
|
|
8
|
+
title: z.string(),
|
|
9
|
+
severity: z.enum(["p1", "p2", "p3"]),
|
|
10
|
+
confidence: z.enum(["high", "medium", "low"]),
|
|
11
|
+
category: z.string(),
|
|
12
|
+
description: z.string(),
|
|
13
|
+
evidence: z.string(),
|
|
14
|
+
recommended_fix: z.string(),
|
|
15
|
+
files: z.array(fileRef),
|
|
16
|
+
}).strict();
|
|
17
|
+
export const reviewFindingsSchema = z.object({
|
|
18
|
+
review_id: z.string(),
|
|
19
|
+
run_id: z.string(),
|
|
20
|
+
reviewer: z.enum(["claude", "codex", "other"]),
|
|
21
|
+
target: z.string(),
|
|
22
|
+
generated_at: z.iso.datetime(),
|
|
23
|
+
summary: z.string(),
|
|
24
|
+
findings: z.array(finding),
|
|
25
|
+
}).strict();
|
|
26
|
+
const verdictFinding = z.object({
|
|
27
|
+
title: z.string(),
|
|
28
|
+
status: z.enum(["confirmed", "contested", "rejected"]),
|
|
29
|
+
reason: z.string(),
|
|
30
|
+
reviewer_ids: z.array(z.string()).optional(),
|
|
31
|
+
final_priority: z.enum(["p1", "p2", "p3"]).optional(),
|
|
32
|
+
}).strict();
|
|
33
|
+
const todoRecommendation = z.object({
|
|
34
|
+
title: z.string(),
|
|
35
|
+
priority: z.enum(["p1", "p2", "p3"]),
|
|
36
|
+
reason: z.string(),
|
|
37
|
+
}).strict();
|
|
38
|
+
export const judgeVerdictSchema = z.object({
|
|
39
|
+
review_id: z.string(),
|
|
40
|
+
run_id: z.string(),
|
|
41
|
+
target: z.string(),
|
|
42
|
+
generated_at: z.iso.datetime(),
|
|
43
|
+
overall_verdict: z.enum(["approve", "needs-fixes", "blocked", "incomplete"]),
|
|
44
|
+
summary_markdown: z.string(),
|
|
45
|
+
confirmed_findings: z.array(verdictFinding),
|
|
46
|
+
contested_findings: z.array(verdictFinding),
|
|
47
|
+
rejected_findings: z.array(verdictFinding),
|
|
48
|
+
todo_recommendations: z.array(todoRecommendation),
|
|
49
|
+
}).strict();
|
|
50
|
+
export const reviewDoneSchema = z.object({
|
|
51
|
+
review_id: z.string(),
|
|
52
|
+
run_id: z.string(),
|
|
53
|
+
reviewer: z.enum(["claude", "codex", "other"]),
|
|
54
|
+
status: z.literal("complete"),
|
|
55
|
+
completed_at: z.iso.datetime(),
|
|
56
|
+
finding_count: z.number().int().min(0),
|
|
57
|
+
}).strict();
|
|
58
|
+
export const judgeDoneSchema = z.object({
|
|
59
|
+
review_id: z.string(),
|
|
60
|
+
run_id: z.string(),
|
|
61
|
+
reviewer: z.literal("judge"),
|
|
62
|
+
status: z.literal("complete"),
|
|
63
|
+
completed_at: z.iso.datetime(),
|
|
64
|
+
confirmed_count: z.number().int().min(0),
|
|
65
|
+
contested_count: z.number().int().min(0),
|
|
66
|
+
rejected_count: z.number().int().min(0),
|
|
67
|
+
}).strict();
|
package/dist/types.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
package/package.json
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@crown-dev-studios/review-council",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Parallel multi-agent code review orchestration with bundled prompts, schemas, and HTML reporting.",
|
|
5
|
+
"packageManager": "pnpm@10.30.3",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"bin": {
|
|
8
|
+
"review-council": "./dist/cli.js"
|
|
9
|
+
},
|
|
10
|
+
"files": [
|
|
11
|
+
"dist/",
|
|
12
|
+
"templates/",
|
|
13
|
+
"schemas/",
|
|
14
|
+
"references/",
|
|
15
|
+
"README.md",
|
|
16
|
+
"SKILL.md"
|
|
17
|
+
],
|
|
18
|
+
"publishConfig": {
|
|
19
|
+
"access": "public"
|
|
20
|
+
},
|
|
21
|
+
"license": "MIT",
|
|
22
|
+
"engines": {
|
|
23
|
+
"node": ">=20"
|
|
24
|
+
},
|
|
25
|
+
"scripts": {
|
|
26
|
+
"build": "rm -rf dist && tsc -p tsconfig.build.json",
|
|
27
|
+
"prepack": "npm run build",
|
|
28
|
+
"pack:dry-run": "npm_config_cache=.npm-cache npm pack --dry-run --json",
|
|
29
|
+
"review-council:orchestrate": "tsx src/cli.ts",
|
|
30
|
+
"review-council:render": "tsx src/render-review-html.ts",
|
|
31
|
+
"typecheck": "tsc --noEmit -p tsconfig.json",
|
|
32
|
+
"test:unit": "node --import tsx/esm --test test/*.test.ts",
|
|
33
|
+
"test:package": "node --test test/package-smoke.test.mjs",
|
|
34
|
+
"test": "pnpm run typecheck && pnpm run test:unit && pnpm run test:package",
|
|
35
|
+
"verify:package": "pnpm run pack:dry-run && pnpm run test:package",
|
|
36
|
+
"release:manual": "bash scripts/prepare-release.sh"
|
|
37
|
+
},
|
|
38
|
+
"devDependencies": {
|
|
39
|
+
"@types/markdown-it": "^14.1.2",
|
|
40
|
+
"@types/node": "^24",
|
|
41
|
+
"tsx": "^4",
|
|
42
|
+
"typescript": "^5"
|
|
43
|
+
},
|
|
44
|
+
"dependencies": {
|
|
45
|
+
"markdown-it": "^14.1.1",
|
|
46
|
+
"zod": "^4.3.5"
|
|
47
|
+
}
|
|
48
|
+
}
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
# CLI Integration
|
|
2
|
+
|
|
3
|
+
The orchestrator accepts literal shell commands via `--claude-command`, `--codex-command`, and `--judge-command`. Context values are passed as environment variables to each child process.
|
|
4
|
+
|
|
5
|
+
Point reviewer CLIs at the rendered prompt files created inside the run directory. This keeps the command templates self-contained and avoids depending on any external `/review-export` command.
|
|
6
|
+
|
|
7
|
+
Pass `--review-id` explicitly when you want run output that is easy to correlate across reruns.
|
|
8
|
+
|
|
9
|
+
Available environment variables:
|
|
10
|
+
|
|
11
|
+
- `$CWD`
|
|
12
|
+
- `$SKILL_DIR`
|
|
13
|
+
- `$REVIEW_ID`
|
|
14
|
+
- `$RUN_ID`
|
|
15
|
+
- `$REVIEW_DIR`
|
|
16
|
+
- `$RUN_DIR`
|
|
17
|
+
- `$CLAUDE_DIR`
|
|
18
|
+
- `$CODEX_DIR`
|
|
19
|
+
- `$JUDGE_DIR`
|
|
20
|
+
- `$REVIEW_SCHEMA`
|
|
21
|
+
- `$JUDGE_SCHEMA`
|
|
22
|
+
|
|
23
|
+
These variables are set in the child process environment. Use standard shell quoting (`"$VAR"`) in command templates.
|
|
24
|
+
|
|
25
|
+
Review targets are available in the rendered prompt templates, but not as environment variables. Keep target text in prompt files rather than interpolating it into reviewer or judge command strings.
|
|
26
|
+
|
|
27
|
+
The orchestrator renders these prompt files before launching any stage:
|
|
28
|
+
|
|
29
|
+
- `$CLAUDE_DIR/claude-review-export.md`
|
|
30
|
+
- `$CODEX_DIR/codex-review-export.md`
|
|
31
|
+
- `$JUDGE_DIR/judge.md`
|
|
32
|
+
|
|
33
|
+
When invoking the orchestrator from the project being reviewed, prefer the published package command:
|
|
34
|
+
|
|
35
|
+
```bash
|
|
36
|
+
npx @crown-dev-studios/review-council --help
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
If the package is already installed, `review-council --help` is equivalent. Invoking the command from the project under review keeps `process.cwd()` anchored there, so the default output path lands in `docs/reviews/` for that project.
|
|
40
|
+
|
|
41
|
+
## Recommended Mode
|
|
42
|
+
|
|
43
|
+
Use raw-review prompts that export findings into the run directory.
|
|
44
|
+
|
|
45
|
+
### Claude
|
|
46
|
+
|
|
47
|
+
Claude supports non-interactive `-p` and built-in `--worktree`.
|
|
48
|
+
|
|
49
|
+
Reviewer commands should already be authenticated and must not require interactive approvals or follow-up answers.
|
|
50
|
+
|
|
51
|
+
Example:
|
|
52
|
+
|
|
53
|
+
```bash
|
|
54
|
+
claude -p --disable-slash-commands --permission-mode acceptEdits \
|
|
55
|
+
"$(cat "$CLAUDE_DIR/claude-review-export.md")" < /dev/null
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
Use `--disable-slash-commands --permission-mode acceptEdits` for artifact-writing review flows, and redirect stdin from `/dev/null` when the stage is fully headless. This keeps Claude in print mode, disables skills, allows artifact writes without interactive approval prompts, and avoids stdin wait warnings.
|
|
59
|
+
|
|
60
|
+
If you want Claude to create an isolated Git worktree, add `--worktree <name>` to the Claude command itself. `review-council` does not own reviewer cwd or worktree paths; that stays inside the raw command you pass.
|
|
61
|
+
|
|
62
|
+
### Codex
|
|
63
|
+
|
|
64
|
+
Codex supports:
|
|
65
|
+
|
|
66
|
+
- `codex review --uncommitted`
|
|
67
|
+
- `codex review --base <branch>`
|
|
68
|
+
- `codex exec`
|
|
69
|
+
|
|
70
|
+
For exact staged-only review, prefer `codex exec` with an explicit prompt.
|
|
71
|
+
|
|
72
|
+
Example:
|
|
73
|
+
|
|
74
|
+
```bash
|
|
75
|
+
codex exec --sandbox workspace-write -o "$CODEX_DIR/last-message.txt" \
|
|
76
|
+
"$(cat "$CODEX_DIR/codex-review-export.md")"
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
Codex reviewer and judge commands must run with a writable sandbox because they need to write artifacts into the stage directory.
|
|
80
|
+
|
|
81
|
+
## Minimal-Change Mode
|
|
82
|
+
|
|
83
|
+
If you want to keep using `workflows-review` unchanged:
|
|
84
|
+
|
|
85
|
+
1. Create a separate worktree per reviewer
|
|
86
|
+
2. Run `workflows-review` inside each worktree
|
|
87
|
+
3. Let each reviewer write to that worktree's local `todos/`
|
|
88
|
+
4. Harvest those todo files into `docs/reviews/<run>/`
|
|
89
|
+
5. Judge the normalized findings afterward
|
|
90
|
+
|
|
91
|
+
This works, but the cleaner long-term shape is export-only reviewer artifacts plus final todo creation after the judge.
|
|
92
|
+
|
|
93
|
+
## Orchestrator Example
|
|
94
|
+
|
|
95
|
+
```bash
|
|
96
|
+
npx @crown-dev-studios/review-council \
|
|
97
|
+
--target "branch main..feature/review-council" \
|
|
98
|
+
--review-id branch-main-feature-review-council \
|
|
99
|
+
--claude-command 'claude -p --disable-slash-commands --permission-mode acceptEdits --worktree review-council-claude "$(cat "$CLAUDE_DIR/claude-review-export.md")" < /dev/null' \
|
|
100
|
+
--codex-command 'codex exec --sandbox workspace-write -o "$CODEX_DIR/last-message.txt" "$(cat "$CODEX_DIR/codex-review-export.md")"' \
|
|
101
|
+
--judge-command 'codex exec --sandbox workspace-write -o "$JUDGE_DIR/last-message.txt" "$(cat "$JUDGE_DIR/judge.md")"'
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
## Resilience Options
|
|
105
|
+
|
|
106
|
+
### Timeout
|
|
107
|
+
|
|
108
|
+
`--timeout <ms>` (default: 300000 — 5 minutes) sets a per-stage deadline. On timeout:
|
|
109
|
+
|
|
110
|
+
1. The child process receives `SIGTERM`
|
|
111
|
+
2. After a 5-second grace period, `SIGKILL` is sent if the process hasn't exited
|
|
112
|
+
3. The stage result records `exit_code: 124` and `timed_out: true`
|
|
113
|
+
|
|
114
|
+
Timed-out stages are not retried.
|
|
115
|
+
|
|
116
|
+
### Retries
|
|
117
|
+
|
|
118
|
+
`--retries <n>` (default: 2) retries a stage up to N times on non-zero exit. Delay between retries uses exponential backoff: `2000 * 2^(attempt-1)` ms (2s, 4s, 8s...). The final `status.json` records `attempts` and `retried` fields.
|
|
119
|
+
|
|
120
|
+
Retries are skipped for timeouts (not transient).
|
|
121
|
+
|
|
122
|
+
### Interactive Prompt Detection
|
|
123
|
+
|
|
124
|
+
The orchestrator monitors each reviewer's stdout for prompt-like output (lines ending with `? `, `: `, `> `, or containing `y/n`, `yes/no`) followed by 3 seconds of silence. When detected, the prompt is relayed to the user's terminal and the response is piped back to the child's stdin.
|
|
125
|
+
|
|
126
|
+
If both reviewers prompt simultaneously, questions are queued and presented one at a time.
|
|
127
|
+
|
|
128
|
+
This is a best-effort safety net. Prefer explicit non-interactive mode (`claude -p --disable-slash-commands --permission-mode acceptEdits < /dev/null`, `codex exec`) when possible.
|
|
129
|
+
|
|
130
|
+
### Schema Validation
|
|
131
|
+
|
|
132
|
+
After each successful process exit, the orchestrator requires the full artifact set (`report.md` + `findings.json` for reviewers, `summary.md` + `verdict.json` for the judge, plus `done.json` unless `--allow-missing-sentinel` is set). It then validates the JSON artifact against its schema. Missing artifacts and validation failures both mark the stage as failed in `status.json`.
|
|
133
|
+
|
|
134
|
+
### Partial Judge Execution
|
|
135
|
+
|
|
136
|
+
The judge runs if at least one reviewer succeeded. The final JSON summary includes a `reviewers_partial` flag and per-reviewer result details.
|
|
137
|
+
|
|
138
|
+
### Skip Judge
|
|
139
|
+
|
|
140
|
+
`--skip-judge` disables judge prompt rendering, judge command validation, and judge execution. This makes reviewer-only runs independent of any configured judge binary.
|
|
141
|
+
|
|
142
|
+
### Judge Inputs
|
|
143
|
+
|
|
144
|
+
The judge template always names the reviewer artifact paths it can inspect. If a listed reviewer directory does not exist in a run, that reviewer did not run and its files should be ignored.
|
|
145
|
+
|
|
146
|
+
## Sentinel Contract
|
|
147
|
+
|
|
148
|
+
The orchestrator waits for:
|
|
149
|
+
|
|
150
|
+
- reviewer exit code `0`
|
|
151
|
+
- reviewer `done.json`
|
|
152
|
+
- judge exit code `0`
|
|
153
|
+
- judge `done.json`
|
|
154
|
+
|
|
155
|
+
If a process exits `0` but omits `done.json`, the stage is treated as incomplete.
|
|
156
|
+
|
|
157
|
+
## Development Runtime
|
|
158
|
+
|
|
159
|
+
The supported consumer runtime is the published package. For local development from a source checkout:
|
|
160
|
+
|
|
161
|
+
```bash
|
|
162
|
+
cd ~/src/review-council
|
|
163
|
+
pnpm install
|
|
164
|
+
pnpm typecheck
|
|
165
|
+
pnpm test
|
|
166
|
+
pnpm verify:package
|
|
167
|
+
```
|
|
168
|
+
|
|
169
|
+
Package scripts are defined in `package.json`:
|
|
170
|
+
|
|
171
|
+
- `pnpm review-council:orchestrate`
|
|
172
|
+
- `pnpm review-council:render`
|
|
173
|
+
- `pnpm typecheck`
|
|
174
|
+
- `pnpm test`
|
|
175
|
+
- `pnpm verify:package`
|
|
176
|
+
|
|
177
|
+
Those scripts are for contributors working inside the package repo itself. For reviewing another project, prefer the published package command above so the docs, package metadata, and runtime contract all stay aligned.
|