Created
August 12, 2026 15:40
-
-
Save vsavkin/d197b4765bfecdb7b47cd0f0b6cf71b5 to your computer and use it in GitHub Desktop.
Interactive Reviews using Polygraph
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/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