{"slug": "a-small-script-implementing-a-tiny-software-factory", "title": "A small script implementing a tiny software factory", "summary": "A developer has created a small script that acts as a software factory, automatically finding Linear issues labeled 'factory' and using Claude and Polygraph to classify and execute them end-to-end. The script queries Linear for Todo issues, asks Claude whether an agent can handle each, and then launches a detached Polygraph session to do the work, updating the issue status accordingly.", "body_md": "| #!/usr/bin/env bun | |\n| // The factory trigger: finds workable Linear issues and hands each one to a | |\n| // detached Polygraph session that does the work end to end. | |\n| // | |\n| // FIND WORK here query Linear for Todo issues (project + label) | |\n| // CLASSIFY here claude -p: can an agent run this issue? | |\n| // DO THE WORK Polygraph one detached session, end to end | |\n| // COMPLETE here attach the session to the issue, move to In Progress | |\n| import { mkdirSync, openSync, readFileSync, writeFileSync } from \"node:fs\"; | |\n| import { homedir } from \"node:os\"; | |\n| import { join } from \"node:path\"; | |\n| const LINEAR_API_URL = \"https://api.linear.app/graphql\"; | |\n| const LOGS_DIR = join(import.meta.dir, \"logs\"); | |\n| const PROJECT_NAME = process.env.LINEAR_PROJECT_NAME; | |\n| const LABEL_NAME = \"factory\"; | |\n| const START_TIMEOUT_MS = 30 * 60_000; | |\n| const apiKey = process.env.LINEAR_API_KEY; | |\n| if (!apiKey) { | |\n| console.error(\"LINEAR_API_KEY is not set.\"); | |\n| console.error(\"Create a personal API key at https://linear.app/settings/api and export it:\"); | |\n| console.error(\" set -x LINEAR_API_KEY lin_api_... # fish\"); | |\n| process.exit(1); | |\n| } | |\n| if (!PROJECT_NAME) { | |\n| console.error(\"LINEAR_PROJECT_NAME is not set.\"); | |\n| console.error('Add it to .env, e.g.: LINEAR_PROJECT_NAME=\"My Project\"'); | |\n| process.exit(1); | |\n| } | |\n| // --------------------------------------------------------------------------- | |\n| // The factory | |\n| // --------------------------------------------------------------------------- | |\n| async function main() { | |\n| const runStartedAt = Date.now(); | |\n| if (process.argv.includes(\"--scheduled\")) { | |\n| if (shouldSkipScheduledRun()) { | |\n| log(\"scheduled run: already ran today — exiting (max one scheduled run per day)\"); | |\n| return; | |\n| } | |\n| log(\"scheduled run: first invocation today\"); | |\n| } | |\n| log(`factory starting — project \"${PROJECT_NAME}\", label \"${LABEL_NAME}\", state Todo`); | |\n| const viewer = await findWork(); | |\n| const issues = viewer.assignedIssues.nodes; | |\n| log(`${issues.length} issue(s) to process for ${viewer.name}`); | |\n| for (const issue of issues) { | |\n| log(`- ${issue.identifier} ${issue.title}`); | |\n| } | |\n| if (issues.length === 0) { | |\n| log(`nothing to do — run finished in ${elapsed(runStartedAt)}`); | |\n| return; | |\n| } | |\n| log(\"loading account info ...\"); | |\n| const account = await whoami(); | |\n| log(`account: ${account.selectedOrganization?.name} (${account.selectedOrgId})`); | |\n| const counts = { started: 0, skipped: 0, error: 0 }; | |\n| for (const issue of issues) { | |\n| log(`processing ${issue.identifier} ${issue.title}`); | |\n| try { | |\n| const outcome = await processIssue(issue, account); | |\n| counts[outcome]++; | |\n| } catch (error) { | |\n| log(`error: ${error.message} — continuing with next issue`); | |\n| counts.error++; | |\n| } | |\n| } | |\n| log( | |\n| `run finished in ${elapsed(runStartedAt)} — ${counts.started} started, ${counts.skipped} skipped, ${counts.error} error(s)`, | |\n| ); | |\n| } | |\n| // Returns \"started\", \"skipped\", or \"error\". | |\n| async function processIssue(issue, account) { | |\n| const classification = await canRunByAgent(issue); | |\n| if (classification.verdict !== \"yes\") { | |\n| log(`skipped: ${classification.reason}`); | |\n| return \"skipped\"; | |\n| } | |\n| log(\"verdict: yes\"); | |\n| // The session agent does the rest: repos, implementation, review, PRs. | |\n| const session = await launchSession(issue, account); | |\n| if (!session) { | |\n| log(\"warning: could not determine session id — leaving issue in Todo so the next run retries\"); | |\n| return \"error\"; | |\n| } | |\n| await complete(issue, session); | |\n| log(`${issue.identifier} done: session running, Linear updated`); | |\n| return \"started\"; | |\n| } | |\n| // --------------------------------------------------------------------------- | |\n| // The steps | |\n| // --------------------------------------------------------------------------- | |\n| // Todo issues assigned to the current user, filtered by project and label. | |\n| async function findWork() { | |\n| const data = await linearQuery( | |\n| ` | |\n| query MyTodoIssues($project: String!, $label: String!) { | |\n| viewer { | |\n| name | |\n| assignedIssues( | |\n| first: 50 | |\n| filter: { | |\n| state: { name: { eq: \"Todo\" } } | |\n| project: { name: { eq: $project } } | |\n| labels: { name: { eq: $label } } | |\n| } | |\n| ) { | |\n| nodes { | |\n| id | |\n| identifier | |\n| title | |\n| description | |\n| url | |\n| team { | |\n| id | |\n| } | |\n| state { | |\n| name | |\n| type | |\n| } | |\n| } | |\n| } | |\n| } | |\n| } | |\n| `, | |\n| { project: PROJECT_NAME, label: LABEL_NAME }, | |\n| ); | |\n| return data.viewer; | |\n| } | |\n| // Ask Claude Code (headless) whether an agent can solve the issue | |\n| // autonomously. Returns { verdict: \"yes\" } or { verdict: \"skip\", reason }. | |\n| async function canRunByAgent(issue) { | |\n| const prompt = `You are screening Linear issues to decide whether a coding agent can pick them up and solve them autonomously. | |\n| Issue ${issue.identifier}: ${issue.title} | |\n| Description: | |\n| ${issue.description?.trim() || \"(no description provided)\"} | |\n| Answer \"yes\" only if all of these hold: | |\n| 1. Actionable: it describes a concrete task or change, not a vague idea or open discussion. | |\n| 2. Somewhat straightforward: it does not require major open-ended design or product decisions. | |\n| 3. Enough context: the description gives enough detail (what to do, where, or how to tell it's done) to start without asking clarifying questions. | |\n| Respond with ONLY a single line of JSON, no markdown, in one of these forms: | |\n| {\"verdict\":\"yes\"} | |\n| {\"verdict\":\"skip\",\"reason\":\"<one short sentence explaining which criterion failed and why>\"}`; | |\n| log(\"classifying with claude -p ...\"); | |\n| const startedAt = Date.now(); | |\n| const proc = Bun.spawn([\"claude\", \"-p\", prompt], { | |\n| stdout: \"pipe\", | |\n| stderr: \"pipe\", | |\n| }); | |\n| const [output, errors] = await Promise.all([ | |\n| new Response(proc.stdout).text(), | |\n| new Response(proc.stderr).text(), | |\n| ]); | |\n| const exitCode = await proc.exited; | |\n| if (exitCode !== 0) { | |\n| throw new Error(`claude -p failed (exit ${exitCode}): ${errors.trim()}`); | |\n| } | |\n| log(`classification finished in ${elapsed(startedAt)}`); | |\n| const match = output.match(/\\{.*\\}/s); | |\n| if (!match) { | |\n| throw new Error(`Could not parse classification output: ${output.trim()}`); | |\n| } | |\n| return JSON.parse(match[0]); | |\n| } | |\n| // In Progress rather than Done: the work is only done once the PRs merge. | |\n| async function complete(issue, session) { | |\n| log(\"attaching session to the Linear issue ...\"); | |\n| await attachSessionToIssue(issue, session); | |\n| log(`moving ${issue.identifier} to In Progress ...`); | |\n| await moveIssueToInProgress(issue); | |\n| } | |\n| // --------------------------------------------------------------------------- | |\n| // Linear machinery | |\n| // --------------------------------------------------------------------------- | |\n| async function linearQuery(query, variables = {}) { | |\n| const opName = query.match(/(?:query|mutation)\\s+(\\w+)/)?.[1] ?? \"anonymous\"; | |\n| const startedAt = Date.now(); | |\n| const res = await fetch(LINEAR_API_URL, { | |\n| method: \"POST\", | |\n| headers: { | |\n| \"Content-Type\": \"application/json\", | |\n| Authorization: apiKey, | |\n| }, | |\n| body: JSON.stringify({ query, variables }), | |\n| }); | |\n| if (!res.ok) { | |\n| throw new Error(`Linear API request failed: ${res.status} ${await res.text()}`); | |\n| } | |\n| const json = await res.json(); | |\n| if (json.errors) { | |\n| throw new Error(`Linear API errors: ${JSON.stringify(json.errors)}`); | |\n| } | |\n| log(`linear: ${opName} ok in ${elapsed(startedAt)}`); | |\n| return json.data; | |\n| } | |\n| // Attachments are idempotent per URL, so re-runs do not create duplicates. | |\n| async function attachSessionToIssue(issue, session) { | |\n| await linearQuery( | |\n| ` | |\n| mutation AttachSession($issueId: String!, $url: String!, $title: String!, $subtitle: String) { | |\n| attachmentCreate( | |\n| input: { issueId: $issueId, url: $url, title: $title, subtitle: $subtitle } | |\n| ) { | |\n| success | |\n| } | |\n| } | |\n| `, | |\n| { | |\n| issueId: issue.id, | |\n| url: session.url, | |\n| title: \"Polygraph session\", | |\n| subtitle: session.sessionId, | |\n| }, | |\n| ); | |\n| } | |\n| async function moveIssueToInProgress(issue) { | |\n| const data = await linearQuery( | |\n| ` | |\n| query InProgressState($teamId: ID!) { | |\n| workflowStates( | |\n| filter: { team: { id: { eq: $teamId } }, name: { eq: \"In Progress\" } } | |\n| ) { | |\n| nodes { | |\n| id | |\n| } | |\n| } | |\n| } | |\n| `, | |\n| { teamId: issue.team.id }, | |\n| ); | |\n| const state = data.workflowStates.nodes[0]; | |\n| if (!state) { | |\n| throw new Error(`No \"In Progress\" state found for team of ${issue.identifier}`); | |\n| } | |\n| await linearQuery( | |\n| ` | |\n| mutation MoveToInProgress($id: String!, $stateId: String!) { | |\n| issueUpdate(id: $id, input: { stateId: $stateId }) { | |\n| success | |\n| } | |\n| } | |\n| `, | |\n| { id: issue.id, stateId: state.id }, | |\n| ); | |\n| } | |\n| // --------------------------------------------------------------------------- | |\n| // Polygraph machinery | |\n| // --------------------------------------------------------------------------- | |\n| // Returns { sessionId, url }, or null when no session id could be obtained. | |\n| async function launchSession(issue, account) { | |\n| const sessionId = await startSession(issue); | |\n| if (!sessionId) return null; | |\n| const url = `${account.url}/orgs/${account.selectedOrgId}/sessions/${sessionId}`; | |\n| log(`session url: ${url}`); | |\n| return { sessionId, url }; | |\n| } | |\n| function sessionPrompt(issue) { | |\n| return `You are working on a Linear issue. All context is below. | |\n| Linear issue: ${issue.identifier} — ${issue.title} | |\n| Linear issue id: ${issue.id} | |\n| Linear issue URL: ${issue.url} | |\n| Description: | |\n| ${issue.description?.trim() || \"(no description provided)\"} | |\n| Complete this task. After you are done with the implementation, check whether it is trivial; if it is not, use the adversarial review skill to review the changes and address the feedback it provides. Open pull requests with your changes, mark them ready for review, and make sure CI is green. Link the Linear issue as a reference on this session.`; | |\n| } | |\n| // `session start` launches the agent detached, prints a final JSON result | |\n| // line with the session id, and exits. --no-primary: the agent picks its | |\n| // repos once inside. Returns the sessionId, or null on timeout. | |\n| async function startSession(issue) { | |\n| const title = slugify(`${issue.identifier} ${issue.title}`).slice(0, 60); | |\n| const prompt = sessionPrompt(issue); | |\n| mkdirSync(LOGS_DIR, { recursive: true }); | |\n| const logPath = join(LOGS_DIR, `${issue.identifier}-start.log`); | |\n| // The log file is append mode; skip lines from any previous run. | |\n| let previousLines = 0; | |\n| try { | |\n| previousLines = (await Bun.file(logPath).text()).split(\"\\n\").length - 1; | |\n| } catch {} | |\n| const args = [ | |\n| \"session\", | |\n| \"start\", | |\n| \"--json\", | |\n| \"--title\", | |\n| title, | |\n| \"--no-primary\", | |\n| \"--multiplexer\", | |\n| \"none\", | |\n| \"--agent\", | |\n| \"claude\", | |\n| \"--\", | |\n| prompt, | |\n| ]; | |\n| const logFile = openSync(logPath, \"a\"); | |\n| const startedAt = Date.now(); | |\n| log( | |\n| `$ polygraph ${args | |\n| .map((a) => (/[\\s'\"]/.test(a) ? `'${a.replaceAll(\"'\", \"'\\\\''\")}'` : a)) | |\n| .join(\" \")}`, | |\n| ); | |\n| log(`full output: ${logPath}`); | |\n| const proc = Bun.spawn( | |\n| [\"polygraph\", ...args], | |\n| // A git cwd would bind the session to this (unconnected) repo. | |\n| { cwd: homedir(), stdin: \"ignore\", stdout: logFile, stderr: logFile }, | |\n| ); | |\n| let timer; | |\n| const timedOut = await Promise.race([ | |\n| proc.exited.then(() => false), | |\n| new Promise((resolve) => { | |\n| timer = setTimeout(() => resolve(true), START_TIMEOUT_MS); | |\n| }), | |\n| ]); | |\n| clearTimeout(timer); | |\n| if (timedOut) { | |\n| log(`session start still running after ${elapsed(startedAt)} — giving up on its output`); | |\n| return null; | |\n| } | |\n| let result = null; | |\n| for (const line of (await Bun.file(logPath).text()).split(\"\\n\").slice(previousLines)) { | |\n| try { | |\n| const event = JSON.parse(line); | |\n| if (event.type === \"result\") result = event; | |\n| } catch {} | |\n| } | |\n| if (!result) { | |\n| log(\"session start exited without a result line\"); | |\n| return null; | |\n| } | |\n| if (!result.success) { | |\n| throw new Error( | |\n| `polygraph session start failed for ${issue.identifier}: ${ | |\n| result.error?.message ?? `see ${logPath}` | |\n| }`, | |\n| ); | |\n| } | |\n| log(`session created in ${elapsed(startedAt)}: ${result.sessionId}`); | |\n| return result.sessionId; | |\n| } | |\n| async function polygraphJson(args) { | |\n| const startedAt = Date.now(); | |\n| const proc = Bun.spawn([\"polygraph\", ...args, \"--json\"], { | |\n| stdin: \"ignore\", | |\n| stdout: \"pipe\", | |\n| stderr: \"pipe\", | |\n| }); | |\n| const output = await new Response(proc.stdout).text(); | |\n| const exitCode = await proc.exited; | |\n| if (exitCode !== 0) { | |\n| throw new Error(`polygraph ${args.join(\" \")} failed (exit ${exitCode})`); | |\n| } | |\n| log(`polygraph: ${args.join(\" \")} ok in ${elapsed(startedAt)}`); | |\n| return JSON.parse(output); | |\n| } | |\n| function whoami() { | |\n| return polygraphJson([\"whoami\"]); | |\n| } | |\n| function slugify(text) { | |\n| return text | |\n| .toLowerCase() | |\n| .replace(/[^a-z0-9]+/g, \"-\") | |\n| .replace(/^-+|-+$/g, \"\"); | |\n| } | |\n| // --------------------------------------------------------------------------- | |\n| // Trigger plumbing | |\n| // --------------------------------------------------------------------------- | |\n| function log(message) { | |\n| const time = new Date().toTimeString().slice(0, 8); | |\n| console.log(`[${time}] ${message}`); | |\n| } | |\n| function elapsed(since) { | |\n| return `${((Date.now() - since) / 1000).toFixed(1)}s`; | |\n| } | |\n| // --scheduled (the LaunchAgent) runs at most once per calendar day; a stamp | |\n| // file dedupes the morning + login/wake firings. Manual runs are never blocked. | |\n| function shouldSkipScheduledRun() { | |\n| const stampPath = join(LOGS_DIR, \"last-scheduled-run.txt\"); | |\n| const today = new Date().toLocaleDateString(\"en-CA\"); | |\n| let last = null; | |\n| try { | |\n| last = readFileSync(stampPath, \"utf8\").trim(); | |\n| } catch {} | |\n| if (last === today) return true; | |\n| mkdirSync(LOGS_DIR, { recursive: true }); | |\n| writeFileSync(stampPath, today); | |\n| return false; | |\n| } | |\n| await main(); |", "url": "https://wpnews.pro/news/a-small-script-implementing-a-tiny-software-factory", "canonical_source": "https://gist.github.com/vsavkin/05537beca162fb8c2437f976f6ecd84d", "published_at": "2026-08-05 20:00:50+00:00", "updated_at": "2026-08-10 09:41:37.785285+00:00", "lang": "en", "topics": ["ai-agents", "developer-tools", "artificial-intelligence"], "entities": ["Linear", "Claude", "Polygraph"], "alternates": {"html": "https://wpnews.pro/news/a-small-script-implementing-a-tiny-software-factory", "markdown": "https://wpnews.pro/news/a-small-script-implementing-a-tiny-software-factory.md", "text": "https://wpnews.pro/news/a-small-script-implementing-a-tiny-software-factory.txt", "jsonld": "https://wpnews.pro/news/a-small-script-implementing-a-tiny-software-factory.jsonld"}}