Skip to content

Instantly share code, notes, and snippets.

@vsavkin
Created August 12, 2026 15:40
Show Gist options
  • Select an option

  • Save vsavkin/d197b4765bfecdb7b47cd0f0b6cf71b5 to your computer and use it in GitHub Desktop.

Select an option

Save vsavkin/d197b4765bfecdb7b47cd0f0b6cf71b5 to your computer and use it in GitHub Desktop.
Interactive Reviews using Polygraph
#!/usr/bin/env bun
// The review trigger: finds in-flight Linear issues owned by others where a
// comment asks the viewer to review, runs an adversarial Polygraph review of
// the linked session, and records the outcome in reviews.json.
//
// Statuses: NOT_STARTED (retried next run), IN_PROGRESS (running, or not yet
// summarized), SUBMITTED (summary stored; never picked up again).
import { mkdirSync, openSync, readFileSync, writeFileSync } from "node:fs";
import { homedir } from "node:os";
import { join } from "node:path";
const LINEAR_API_URL = "https://api.linear.app/graphql";
const LOGS_DIR = join(import.meta.dir, "logs");
const REVIEWS_FILE = join(import.meta.dir, "reviews.json");
const PROJECT_NAME = process.env.LINEAR_PROJECT_NAME;
const REVIEW_STATES = ["In Progress", "In Review"];
// Multi-repo sessions with big clones have blown through a 30-minute budget.
const REVIEW_TIMEOUT_MS = 90 * 60_000;
const ASK_TIMEOUT_MS = 5 * 60_000;
// Classification/summarization only; the review runs on the default model.
const CLASSIFIER_MODEL = "haiku";
const apiKey = process.env.LINEAR_API_KEY;
if (!apiKey) {
console.error("LINEAR_API_KEY is not set.");
console.error("Create a personal API key at https://linear.app/settings/api and export it:");
console.error(" set -x LINEAR_API_KEY lin_api_... # fish");
process.exit(1);
}
if (!PROJECT_NAME) {
console.error("LINEAR_PROJECT_NAME is not set.");
console.error('Add it to .env, e.g.: LINEAR_PROJECT_NAME="My Project"');
process.exit(1);
}
// ---------------------------------------------------------------------------
// The review loop
// ---------------------------------------------------------------------------
// Quiet by default; --verbose explains every skip.
async function main() {
const runStartedAt = Date.now();
// `--session <id-or-url>`: review one session directly, no Linear involved.
const sessionArg = process.argv.indexOf("--session");
if (sessionArg !== -1) {
await reviewSessionDirectly(process.argv[sessionArg + 1]);
return;
}
if (process.argv.includes("--scheduled")) {
if (shouldSkipScheduledRun()) {
log("scheduled run: last run was under 1.5 hours ago — exiting");
return;
}
log("scheduled run: proceeding");
}
const { viewer, issues } = await findWork();
const reviews = loadReviews();
debug(`${issues.length} issue(s) in ${REVIEW_STATES.join(" / ")} not assigned to ${viewer.name}`);
const counts = { reviewed: 0, skipped: 0, error: 0 };
for (const issue of issues) {
try {
const outcome = await processIssue(issue, viewer, reviews);
counts[outcome]++;
} catch (error) {
log(`error on ${issue.identifier}: ${error.message} — continuing with next issue`);
counts.error++;
}
}
log(
`checked ${issues.length} issue(s) in ${elapsed(runStartedAt)} — ${counts.reviewed} review(s) submitted, ${counts.error} error(s)`,
);
}
// Returns "reviewed", "skipped", or "error".
async function processIssue(issue, viewer, reviews) {
const existing = reviews.find((entry) => entry.issue === issue.identifier);
// COMPLETED means Victor has dealt with the submitted review (reviews-tui.js).
if (existing?.status === "SUBMITTED" || existing?.status === "COMPLETED") {
log(`${label(issue)} — already reviewed on ${existing.updatedAt} (${existing.status}, see reviews.json)`);
return "skipped";
}
// Cleared items live in the archive; re-reviewing them would be a repeat.
if (!existing && isArchivedIssue(issue.identifier)) {
debug(`${label(issue)} — reviewed and cleared earlier (reviews-archive.json)`);
return "skipped";
}
// IN_PROGRESS without a claude session id means another run owns it; only
// entries older than the review timeout are treated as crashed and rerun.
if (
existing?.status === "IN_PROGRESS" &&
!existing.claudeSessionId &&
Date.now() - Date.parse(existing.updatedAt) < REVIEW_TIMEOUT_MS
) {
log(`${label(issue)} — review running elsewhere since ${existing.updatedAt}, leaving it alone`);
return "skipped";
}
// An existing entry already passed classification on a previous run.
if (!existing) {
const classification = await isReviewRequested(issue, viewer);
if (classification.verdict !== "yes") {
if (classification.screenedBy === "claude") {
// Near-misses are what to look at when an expected review is missing.
log(`${label(issue)} — not a review request: ${classification.reason}`);
} else {
debug(`${label(issue)} — skipped: ${classification.reason}`);
}
return "skipped";
}
}
log(`${label(issue)} — review requested`);
const sessionId = existing?.sessionId ?? findSessionId(issue) ?? (await matchSessionByPr(issue));
if (!sessionId) {
throw new Error("no Polygraph session linked on the issue or matched by its PRs");
}
let entry = existing;
if (!entry) {
entry = {
sessionId,
issue: issue.identifier,
description: `${issue.identifier} ${issue.title}`,
summary: null,
overview: null,
status: "NOT_STARTED",
claudeSessionId: null,
updatedAt: null,
};
reviews.push(entry);
}
await runReviewPipeline(entry, reviews, `This session implements Linear issue ${issue.identifier}: ${issue.title}.`);
log(`${issue.identifier} review submitted`);
return "reviewed";
}
// Review a session with no Linear issue; an existing entry is redone from
// scratch, a running one is left alone.
async function reviewSessionDirectly(input) {
if (!input) {
console.error("Usage: review.js --session <session-id-or-url>");
process.exit(1);
}
const sessionId = extractSessionId(input);
const reviews = loadReviews();
let entry = reviews.find((e) => e.sessionId === sessionId);
if (
entry?.status === "IN_PROGRESS" &&
!entry.claudeSessionId &&
Date.now() - Date.parse(entry.updatedAt) < REVIEW_TIMEOUT_MS
) {
log(`${sessionId} — review running elsewhere since ${entry.updatedAt}, leaving it alone`);
return;
}
const session = await polygraphJson(["session", "show", sessionId]);
if (!entry) {
entry = {
sessionId,
issue: null,
description: session.title ? `${session.title} (added by hand)` : sessionId,
summary: null,
overview: null,
status: "NOT_STARTED",
claudeSessionId: null,
updatedAt: null,
};
reviews.push(entry);
saveReviews(reviews);
} else if (entry.summary || entry.claudeSessionId) {
log(`${entry.description} — reviewed before, re-reviewing from scratch`);
entry.summary = null;
entry.overview = null;
entry.claudeSessionId = null;
}
log(`${entry.description} — reviewing session ${sessionId}`);
await runReviewPipeline(entry, reviews, "");
log(`${sessionId} review submitted`);
}
// Adversarial review, then two follow-up questions, persisting state at each
// step so a retry after a partial failure only redoes what is missing.
async function runReviewPipeline(entry, reviews, issueLine) {
// claudeSessionId is only persisted once a review completed; an entry
// without one runs the review, with one it skips to the follow-ups.
if (!entry.claudeSessionId) {
setStatus(reviews, entry, "IN_PROGRESS");
try {
entry.claudeSessionId = await runAdversarialReview(entry, issueLine);
saveReviews(reviews);
} catch (error) {
setStatus(reviews, entry, "NOT_STARTED");
throw error;
}
}
if (!entry.overview) {
entry.overview = await askParent(
entry.claudeSessionId,
"Can you provide a high level view of this effort? A few things I should note. If applicable, use ASCII art to help me.",
);
saveReviews(reviews);
log("high-level overview stored (open reviews-tui to read it)");
}
if (!entry.summary) {
entry.summary = await askParent(
entry.claudeSessionId,
"Sum up the adversarial review you just ran in 2-4 sentences: overall verdict, the most important findings, and whether anything blocks approving this work. Respond with plain text only.",
CLASSIFIER_MODEL,
);
}
setStatus(reviews, entry, "SUBMITTED");
for (const line of entry.summary.split("\n")) {
log(` ${line}`);
}
}
// ---------------------------------------------------------------------------
// The steps
// ---------------------------------------------------------------------------
// In Progress / In Review issues minus the viewer's own — a review request
// only makes sense on someone else's issue. Unassigned issues stay in.
async function findWork() {
const data = await linearQuery(
`
query ReviewCandidates($project: String!, $states: [String!]!) {
viewer {
id
name
}
issues(
first: 50
filter: {
state: { name: { in: $states } }
project: { name: { eq: $project } }
}
) {
nodes {
id
identifier
title
url
assignee {
id
name
}
attachments(first: 20) {
nodes {
title
subtitle
url
}
}
comments(first: 50) {
nodes {
body
createdAt
user {
name
}
}
}
}
}
}
`,
{ project: PROJECT_NAME, states: REVIEW_STATES },
);
const viewer = data.viewer;
const issues = data.issues.nodes.filter((issue) => issue.assignee?.id !== viewer.id);
return { viewer, issues };
}
// Does any comment ask the viewer to review this issue? Returns { verdict,
// reason?, screenedBy }; a name pre-filter skips the claude call.
async function isReviewRequested(issue, viewer) {
const comments = [...issue.comments.nodes].sort(
(a, b) => Date.parse(a.createdAt) - Date.parse(b.createdAt),
);
if (comments.length === 0) {
return { verdict: "no", reason: "no comments", screenedBy: "pre-filter" };
}
const nameParts = viewer.name.toLowerCase().split(/\s+/);
const mentionsViewer = comments.some((comment) =>
nameParts.some((part) => comment.body.toLowerCase().includes(part)),
);
if (!mentionsViewer) {
return { verdict: "no", reason: `no comment mentions ${viewer.name}`, screenedBy: "pre-filter" };
}
debug(`${issue.identifier} mentions ${viewer.name} — classifying with claude -p (${CLASSIFIER_MODEL}) ...`);
const transcript = comments
.map((comment) => `[${comment.user?.name ?? "unknown"}] ${comment.body}`)
.join("\n---\n");
const prompt = `You are screening comments on a Linear issue to decide whether anyone asked ${viewer.name} to review it.
Issue ${issue.identifier}: ${issue.title}
Comments (oldest first, separated by ---):
${transcript}
Answer "yes" only if a comment asks, or clearly implies, that ${viewer.name} should review this issue. Merely mentioning ${viewer.name} in some other context does not count. The comment's author does not matter: a note by anyone — including ${viewer.name} themselves — saying ${viewer.name} should review it counts as yes.
Respond with ONLY a single line of JSON, no markdown, in one of these forms:
{"verdict":"yes"}
{"verdict":"no","reason":"<one short sentence explaining why not>"}`;
const proc = Bun.spawn(["claude", "-p", "--model", CLASSIFIER_MODEL, prompt], {
stdout: "pipe",
stderr: "pipe",
});
const [output, errors] = await Promise.all([
new Response(proc.stdout).text(),
new Response(proc.stderr).text(),
]);
const exitCode = await proc.exited;
if (exitCode !== 0) {
throw new Error(`claude -p failed (exit ${exitCode}): ${errors.trim()}`);
}
const match = output.match(/\{.*\}/s);
if (!match) {
throw new Error(`Could not parse classification output: ${output.trim()}`);
}
return { ...JSON.parse(match[0]), screenedBy: "claude" };
}
// The session URL is attached by the factory or pasted into a comment.
function findSessionId(issue) {
const texts = [
...issue.attachments.nodes.map((a) => `${a.url ?? ""} ${a.subtitle ?? ""}`),
...issue.comments.nodes.map((c) => c.body),
];
for (const text of texts) {
const match = text.match(/\/(?:sessions|s)\/([A-Za-z0-9._-]+)/);
if (match) return match[1];
}
return null;
}
// Match the issue's PRs against the PRs of recent Polygraph sessions.
async function matchSessionByPr(issue) {
const prUrls = issuePrUrls(issue);
if (prUrls.size === 0) return null;
for (const session of await recentSessions()) {
const match = session.prUrls.find((url) => prUrls.has(url));
if (match) {
log(`matched session ${session.sessionId} via PR ${match}`);
return session.sessionId;
}
}
return null;
}
// `polygraph session review --adversarial` is interactive-only, so this runs
// its recipe headlessly; returns the claude session id for the follow-ups.
async function runAdversarialReview(entry, issueLine) {
const session = await polygraphJson(["session", "show", entry.sessionId]);
const repoList =
session.repositories?.map((repo) => `- ${repo.fullName}${repo.initiator ? " (initiator)" : ""}`).join("\n") ??
"- No repositories available yet";
const prompt = `You're in Polygraph session "${session.sessionId}".
Organization: ${session.orgId}
Session URL: ${session.polygraphSessionUrl}
Repositories:
${repoList}
${issueLine ? `\n${issueLine}\n` : ""}
Your task:
Do not make code, branch, PR, or other repository changes. Load the adversarial-review skill (polygraph:adversarial-review) and follow it to run an adversarial review of this session. You are running headlessly with no user available, so never ask questions: use claude as the reviewer agent, and once every reviewer is back, present the consolidated review summary as your final message and stop — do not address the feedback and do not upload artifacts.`;
mkdirSync(LOGS_DIR, { recursive: true });
const logPath = join(LOGS_DIR, `${entry.issue ?? entry.sessionId}-review.log`);
// The log file is append mode; skip lines from any previous run.
let previousLines = 0;
try {
previousLines = (await Bun.file(logPath).text()).split("\n").length - 1;
} catch {}
log(`session ${entry.sessionId} — adversarial review starting (full output: ${logPath})`);
const logFile = openSync(logPath, "a");
const startedAt = Date.now();
const proc = Bun.spawn(
["claude", "-p", "--output-format", "stream-json", "--verbose", "--permission-mode", "bypassPermissions", prompt],
// A git cwd would bind session storage here; askParent must use the same cwd.
{ cwd: homedir(), stdin: "ignore", stdout: logFile, stderr: logFile },
);
let timer;
const timedOut = await Promise.race([
proc.exited.then(() => false),
new Promise((resolve) => {
timer = setTimeout(() => resolve(true), REVIEW_TIMEOUT_MS);
}),
]);
clearTimeout(timer);
if (timedOut) {
proc.kill();
throw new Error(`adversarial review still running after ${elapsed(startedAt)} — killed, see ${logPath}`);
}
let result = null;
for (const line of (await Bun.file(logPath).text()).split("\n").slice(previousLines)) {
try {
const event = JSON.parse(line);
if (event.type === "result") result = event;
} catch {}
}
if (!result || result.is_error) {
throw new Error(`adversarial review failed — see ${logPath}`);
}
log(`adversarial review finished in ${elapsed(startedAt)}`);
return result.session_id;
}
// Ask the review conversation a follow-up; model overrides the default.
async function askParent(claudeSessionId, question, model) {
const args = ["claude", "-p", "--resume", claudeSessionId];
if (model) args.push("--model", model);
args.push(question);
const proc = Bun.spawn(args, {
cwd: homedir(),
stdin: "ignore",
stdout: "pipe",
stderr: "pipe",
});
let timer;
const outcome = await Promise.race([
Promise.all([new Response(proc.stdout).text(), new Response(proc.stderr).text(), proc.exited]),
new Promise((resolve) => {
timer = setTimeout(() => resolve(null), ASK_TIMEOUT_MS);
}),
]);
clearTimeout(timer);
if (!outcome) {
proc.kill();
throw new Error("follow-up question to the review agent timed out");
}
const [output, errors, exitCode] = outcome;
if (exitCode !== 0) {
throw new Error(`follow-up question failed (exit ${exitCode}): ${errors.trim()}`);
}
const answer = output.trim();
if (!answer) {
throw new Error("follow-up question returned no output");
}
return answer;
}
// ---------------------------------------------------------------------------
// Session matching
// ---------------------------------------------------------------------------
function extractSessionId(input) {
const match = input.match(/\/(?:sessions|s)\/([A-Za-z0-9._-]+)/);
return match ? decodeURIComponent(match[1]) : input.trim();
}
// Attachments (GitHub sync) are reliable; comments can mention unrelated PRs.
function issuePrUrls(issue) {
const fromAttachments = extractPrUrls(issue.attachments.nodes.map((a) => a.url ?? ""));
if (fromAttachments.size > 0) return fromAttachments;
return extractPrUrls(issue.comments.nodes.map((c) => c.body));
}
function extractPrUrls(texts) {
const urls = new Set();
for (const text of texts) {
for (const match of text.matchAll(/github\.com\/[\w.-]+\/[\w.-]+\/pull\/\d+/g)) {
urls.add(normalizePrUrl(match[0]));
}
}
return urls;
}
function normalizePrUrl(url) {
const match = String(url).match(/github\.com\/([\w.-]+)\/([\w.-]+)\/pull\/(\d+)/i);
return match ? `${match[1].toLowerCase()}/${match[2].toLowerCase()}#${match[3]}` : null;
}
// All authors, newest first; one `session show` each, so loaded lazily once.
let recentSessionsPromise = null;
function recentSessions() {
recentSessionsPromise ??= loadRecentSessions();
return recentSessionsPromise;
}
const SESSION_SHOW_CONCURRENCY = 8;
async function loadRecentSessions() {
const list = await polygraphJson(["session", "list", "--filter.author", "*", "--filter.hasPrs"]);
log(`matching against ${list.length} recent session(s) with PRs ...`);
const queue = [...list].reverse(); // the list is oldest first
const sessions = [];
await Promise.all(
Array.from({ length: SESSION_SHOW_CONCURRENCY }, async () => {
while (queue.length > 0) {
const item = queue.shift();
try {
const session = await polygraphJson(["session", "show", item.name]);
sessions.push({
sessionId: session.sessionId,
order: list.indexOf(item),
prUrls: (session.pullRequests ?? []).map((pr) => normalizePrUrl(pr.url)).filter(Boolean),
});
} catch {
// A session that fails to load can't be matched.
}
}
}),
);
return sessions.sort((a, b) => b.order - a.order); // newest first
}
// ---------------------------------------------------------------------------
// reviews.json
// ---------------------------------------------------------------------------
function loadReviews() {
try {
return JSON.parse(readFileSync(REVIEWS_FILE, "utf8"));
} catch {
return [];
}
}
// Issues cleared by reviews-tui's "clear completed" — still off limits.
let archivedIssues = null;
function isArchivedIssue(issueId) {
if (!archivedIssues) {
try {
const archive = JSON.parse(readFileSync(join(import.meta.dir, "reviews-archive.json"), "utf8"));
archivedIssues = new Set(archive.map((entry) => entry.issue).filter(Boolean));
} catch {
archivedIssues = new Set();
}
}
return archivedIssues.has(issueId);
}
function saveReviews(reviews) {
writeFileSync(REVIEWS_FILE, JSON.stringify(reviews, null, 2) + "\n");
}
function setStatus(reviews, entry, status) {
entry.status = status;
entry.updatedAt = new Date().toISOString();
saveReviews(reviews);
}
// ---------------------------------------------------------------------------
// Linear and Polygraph machinery
// ---------------------------------------------------------------------------
async function linearQuery(query, variables = {}) {
const res = await fetch(LINEAR_API_URL, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: apiKey,
},
body: JSON.stringify({ query, variables }),
});
if (!res.ok) {
throw new Error(`Linear API request failed: ${res.status} ${await res.text()}`);
}
const json = await res.json();
if (json.errors) {
throw new Error(`Linear API errors: ${JSON.stringify(json.errors)}`);
}
return json.data;
}
async function polygraphJson(args) {
const proc = Bun.spawn(["polygraph", ...args, "--json"], {
stdin: "ignore",
stdout: "pipe",
stderr: "pipe",
});
const output = await new Response(proc.stdout).text();
const exitCode = await proc.exited;
if (exitCode !== 0) {
throw new Error(`polygraph ${args.join(" ")} failed (exit ${exitCode})`);
}
return JSON.parse(output);
}
// ---------------------------------------------------------------------------
// Trigger plumbing
// ---------------------------------------------------------------------------
function log(message) {
const time = new Date().toTimeString().slice(0, 8);
console.log(`[${time}] ${message}`);
}
const VERBOSE = process.argv.includes("--verbose") || process.argv.includes("-v");
function debug(message) {
if (VERBOSE) log(message);
}
function label(issue) {
return `${issue.identifier} ${issue.title} (${issue.assignee?.name ?? "unassigned"})`;
}
function elapsed(since) {
return `${((Date.now() - since) / 1000).toFixed(1)}s`;
}
// --scheduled (the LaunchAgent) fires every 2 hours plus at login/wake; a stamp
// file skips firings closer together than that. Manual runs are never blocked.
const MIN_SCHEDULED_GAP_MS = 1.5 * 60 * 60 * 1000;
function shouldSkipScheduledRun() {
const stampPath = join(LOGS_DIR, "last-scheduled-review-run.txt");
let last = NaN;
try {
last = Date.parse(readFileSync(stampPath, "utf8").trim());
} catch {}
if (!Number.isNaN(last) && Date.now() - last < MIN_SCHEDULED_GAP_MS) return true;
mkdirSync(LOGS_DIR, { recursive: true });
writeFileSync(stampPath, new Date().toISOString());
return false;
}
await main();
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment