Skip to content

Instantly share code, notes, and snippets.

@roninjin10
Created March 9, 2026 22:51
Show Gist options
  • Select an option

  • Save roninjin10/57d6359968c8770ab5451cd70255b06a to your computer and use it in GitHub Desktop.

Select an option

Save roninjin10/57d6359968c8770ab5451cd70255b06a to your computer and use it in GitHub Desktop.
Kinda symphany but uses smithers
#!/usr/bin/env bun
/**
* JJHub Smithers Workflow — batch runner for Linear tickets.
*
* Usage:
* bun scripts/smithers.tsx --team JJH --backfill --all-open --batch
* bun scripts/smithers.tsx --team JJH --backfill --all-open --batch --limit 2 --concurrency 2
* bun scripts/smithers.tsx --team JJH --backfill --all-open --batch --limit 1 --dry-run
*
* Env:
* LINEAR_API_KEY — Required for Linear integration
*/
import { parseArgs } from "node:util";
import { z } from "zod";
import {
createSmithers,
Sequence,
Parallel,
Branch,
Ralph,
Worktree,
ClaudeCodeAgent,
CodexAgent,
GeminiAgent,
runWorkflow,
} from "smithers-orchestrator";
import { getLinearClient } from "smithers-orchestrator/linear";
import type { LinearIssue, LinearIssueStatus } from "smithers-orchestrator/linear";
import ResearchPrompt from "./prompts/research.mdx";
import PlanPrompt from "./prompts/plan.mdx";
import ImplementPrompt from "./prompts/implement.mdx";
import ReviewPrompt from "./prompts/review.mdx";
// ---------------------------------------------------------------------------
// CLI args — validated with zod
// ---------------------------------------------------------------------------
const { values: rawArgs } = parseArgs({
args: Bun.argv.slice(2),
options: {
team: { type: "string", default: "JJH" },
concurrency: { type: "string", default: "3" },
"dry-run": { type: "boolean", default: false },
backfill: { type: "boolean", default: false },
"all-open": { type: "boolean", default: false },
batch: { type: "boolean", default: false },
limit: { type: "string" },
"trigger-label": { type: "string", default: "smithers" },
},
});
const argsSchema = z
.object({
team: z.string().min(1),
concurrency: z.coerce.number().int().min(1).max(20),
dryRun: z.boolean(),
backfill: z.boolean(),
allOpen: z.boolean(),
batch: z.boolean(),
limit: z.coerce.number().int().positive().nullable(),
triggerLabel: z.string().min(1),
})
.refine((a) => a.backfill || a.batch, {
message: "--backfill or --batch is required (webhook mode removed)",
});
const args = argsSchema.parse({
team: rawArgs.team,
concurrency: rawArgs.concurrency,
dryRun: rawArgs["dry-run"],
backfill: rawArgs.backfill,
allOpen: rawArgs["all-open"],
batch: rawArgs.batch,
limit: rawArgs.limit ?? null,
triggerLabel: rawArgs["trigger-label"],
});
// ---------------------------------------------------------------------------
// Schemas
// ---------------------------------------------------------------------------
const linearIssueSchema = z.object({
id: z.string(),
identifier: z.string(),
title: z.string(),
description: z.string().nullable(),
priority: z.number(),
priorityLabel: z.string(),
state: z.object({ id: z.string(), name: z.string(), type: z.string() }).nullable(),
assignee: z.object({ id: z.string(), name: z.string(), email: z.string() }).nullable(),
labels: z.array(z.object({ id: z.string(), name: z.string() })),
project: z.object({ id: z.string(), name: z.string() }).nullable(),
createdAt: z.string(),
updatedAt: z.string(),
url: z.string(),
});
const { Workflow, Task, smithers, outputs } = createSmithers({
setup: z.object({
issues: z.array(linearIssueSchema),
doneStateId: z.string().nullable(),
}),
research: z.object({
ticketId: z.string(),
summary: z.string(),
relevantFiles: z.array(z.string()),
isFrontend: z.boolean(),
complexity: z.enum(["low", "medium", "high"]),
}),
plan: z.object({
approach: z.string(),
steps: z.array(z.string()),
risks: z.array(z.string()),
estimatedFileCount: z.number(),
}),
implement: z.object({
filesChanged: z.array(z.string()),
summary: z.string(),
testsAdded: z.boolean(),
}),
review: z.object({
reviewer: z.string(),
verdict: z.enum(["LGTM", "NEEDS_CHANGES"]),
comments: z.array(z.string()),
}),
linearUpdate: z.object({
ticketId: z.string(),
newStateId: z.string(),
commentId: z.string(),
success: z.boolean(),
}),
});
// ---------------------------------------------------------------------------
// Agents
// ---------------------------------------------------------------------------
const TASK_RETRIES = 3;
const WORKTREE_DIR = `${process.cwd()}/.smithers-worktrees`;
const LINEAR_API_INSTRUCTIONS = `
## Linear API (for posting progress updates)
You have access to the Linear GraphQL API via curl. Use it to post comments on the issue you're working on.
The API key is in the LINEAR_API_KEY environment variable.
Post a comment:
curl -s -X POST https://api.linear.app/graphql \\
-H "Content-Type: application/json" \\
-H "Authorization: $LINEAR_API_KEY" \\
-d '{"query":"mutation{commentCreate(input:{issueId:\\"ISSUE_ID\\",body:\\"Your markdown comment\\"}){success}}"}'
Update issue state:
curl -s -X POST https://api.linear.app/graphql \\
-H "Content-Type: application/json" \\
-H "Authorization: $LINEAR_API_KEY" \\
-d '{"query":"mutation{issueUpdate(id:\\"ISSUE_ID\\",input:{stateId:\\"STATE_ID\\"}){success}}"}'
Use this to post a brief progress comment when you START your phase (e.g. "Starting research..." or "Reviewing implementation...").
Keep comments concise — 1-3 sentences.`;
const claudeResearcher = new ClaudeCodeAgent({
id: "claude-researcher",
model: "claude-opus-4-6",
systemPrompt: `You are a senior engineer researching a ticket for JJHub, a jj-native code hosting platform.
Your job is to understand the ticket, find relevant files in the codebase, and classify the work.
The project is a Go API server + Rust CLI/repo-host. Check docs/specs/ for architecture.
Respond with structured JSON matching the output schema.
${LINEAR_API_INSTRUCTIONS}`,
});
const codexPlanner = new CodexAgent({
id: "codex-planner",
model: "gpt-5.4",
systemPrompt: `You are a senior architect planning implementation for JJHub tickets.
Given research output, create a concrete implementation plan with steps, risks, and file estimates.
The project uses Go (Chi router, sqlc, pgxpool) for the API and Rust (clap, jj-lib) for CLI/repo-host.
Respond with structured JSON matching the output schema.
${LINEAR_API_INSTRUCTIONS}`,
});
const codexImplementer = new CodexAgent({
id: "codex-implementer",
model: "gpt-5.4",
systemPrompt: `You are a senior Go/Rust engineer implementing JJHub features.
Follow the plan exactly. Write clean, idiomatic code. Use existing patterns from the codebase.
Go API: Chi router, sqlc queries, services layer. Rust: clap CLI, jj-lib.
Read specs in docs/specs/ before writing code. Run tests after changes.`,
});
const geminiImplementer = new GeminiAgent({
id: "gemini-implementer",
model: "gemini-3.1-pro",
systemPrompt: `You are a senior frontend/full-stack engineer implementing JJHub features.
Follow the plan exactly. Write clean, idiomatic code. Use existing patterns from the codebase.
Read specs in docs/specs/ before writing code. Run tests after changes.`,
});
function makeReviewer(
AgentClass: typeof ClaudeCodeAgent | typeof CodexAgent | typeof GeminiAgent,
id: string,
model: string,
focusAreas: string,
) {
return new AgentClass({
id,
model,
systemPrompt: `You are a senior code reviewer for JJHub.
Review the implementation changes critically. Focus on:
${focusAreas}
Set verdict to "LGTM" only if the code is production-ready.
${LINEAR_API_INSTRUCTIONS}`,
});
}
const claudeReviewer = makeReviewer(
ClaudeCodeAgent, "claude-reviewer", "claude-opus-4-6",
`- Security issues (OWASP top 10)
- Error handling and edge cases
- Adherence to project patterns (Chi, sqlc, services layer)
- Correctness and completeness vs the plan`,
);
const codexReviewer = makeReviewer(
CodexAgent, "codex-reviewer", "gpt-5.4",
`- Performance implications
- Test coverage
- Logical correctness and edge cases
- API design consistency`,
);
const geminiReviewer = makeReviewer(
GeminiAgent, "gemini-reviewer", "gemini-3.1-pro",
`- Code organization and readability
- Documentation and naming
- Type safety
- Potential regressions
Only give LGTM if the test cases give you confidence it works and you can't think of improvements.`,
);
// ---------------------------------------------------------------------------
// Compute task functions
// ---------------------------------------------------------------------------
async function setupTask() {
const client = getLinearClient();
const teamsResult = await client.teams();
const team = teamsResult.nodes.find((t: any) => t.key === args.team);
if (!team) throw new Error(`Team "${args.team}" not found. Available: ${teamsResult.nodes.map((t: any) => t.key).join(", ")}`);
const statesResult = await (await client.team(team.id)).states();
const statuses: LinearIssueStatus[] = statesResult.nodes.map((s: any) => ({
id: s.id, name: s.name, type: s.type, position: s.position,
}));
const fetchedIssues = await fetchBackfillIssues(team.id, {
allOpen: args.allOpen,
triggerLabel: args.triggerLabel,
limit: args.limit,
});
console.log(`Found ${fetchedIssues.length} issue(s)`);
if (args.dryRun) {
console.log("\n[DRY RUN] Would process:");
for (const issue of fetchedIssues) console.log(` ${issue.identifier}: ${issue.title}`);
return { issues: [] as LinearIssue[], doneStateId: null };
}
await Bun.write(`${WORKTREE_DIR}/.gitkeep`, "");
const inProgressState = statuses.find((s) => s.type === "started" && s.name.toLowerCase().includes("progress"));
for (const issue of fetchedIssues) {
if (inProgressState) await client.updateIssue(issue.id, { stateId: inProgressState.id });
}
return {
issues: fetchedIssues,
doneStateId: statuses.find((s) => s.type === "completed")?.id ?? null,
};
}
async function mergeTask(opts: {
ticket: LinearIssue;
branchName: string;
worktreePath: string;
tid: string;
doneStateId: string | null;
reviews: { claude?: string; codex?: string; gemini?: string };
implement?: { filesChanged?: string[]; summary?: string };
}) {
if (args.dryRun) {
return { ticketId: opts.ticket.identifier, newStateId: opts.doneStateId ?? "dry-run", commentId: "dry-run", success: true };
}
const { $ } = await import("bun");
$.cwd(process.cwd());
await $`jj squash --from ${opts.branchName} --into main -m ${`${opts.ticket.identifier}: ${opts.ticket.title}`}`;
console.log(` Squashed ${opts.branchName} into main`);
await $`jj workspace forget ${`wt-${opts.tid}`}`.nothrow();
await $`rm -rf ${opts.worktreePath}`.nothrow();
console.log(` Cleaned up worktree ${opts.worktreePath}`);
const linearClient = getLinearClient();
const reviewSummary = [
`Claude: ${opts.reviews.claude ?? "N/A"}`,
`Codex: ${opts.reviews.codex ?? "N/A"}`,
`Gemini: ${opts.reviews.gemini ?? "N/A"}`,
].join(" | ");
const body = [
`## Smithers Workflow Complete`,
"",
`**Reviews:** ${reviewSummary}`,
`**Branch:** \`${opts.branchName}\` → squashed into main`,
"",
`**Files changed:** ${opts.implement?.filesChanged?.join(", ") ?? "none"}`,
"",
`**Summary:** ${opts.implement?.summary ?? "No summary"}`,
].join("\n");
const commentResult = await linearClient.createComment({ issueId: opts.ticket.id, body });
const comment = await commentResult.comment;
if (opts.doneStateId) {
await linearClient.updateIssue(opts.ticket.id, { stateId: opts.doneStateId });
}
return { ticketId: opts.ticket.identifier, newStateId: opts.doneStateId ?? "", commentId: comment?.id ?? "", success: true };
}
async function fetchBackfillIssues(
teamId: string,
opts: { allOpen: boolean; triggerLabel: string; limit: number | null },
): Promise<LinearIssue[]> {
const apiKey = process.env.LINEAR_API_KEY;
if (!apiKey) throw new Error("LINEAR_API_KEY is required");
const query = `
query SmithersBackfillIssues($teamId: ID!, $after: String) {
issues(
first: 50
after: $after
filter: { team: { id: { eq: $teamId } } }
) {
pageInfo { hasNextPage endCursor }
nodes {
id identifier title description priority priorityLabel
state { id name type }
assignee { id name email }
labels { nodes { id name } }
project { id name }
createdAt updatedAt url
}
}
}
`;
const issues: LinearIssue[] = [];
let after: string | null = null;
while (true) {
const response = await fetch("https://api.linear.app/graphql", {
method: "POST",
headers: { "content-type": "application/json", authorization: apiKey },
body: JSON.stringify({ query, variables: { teamId, after } }),
});
if (!response.ok) {
throw new Error(`Linear query failed: HTTP ${response.status}: ${await response.text()}`);
}
const payload = (await response.json()) as {
data?: {
issues?: {
pageInfo?: { hasNextPage: boolean; endCursor: string | null };
nodes?: Array<{
id: string;
identifier: string;
title: string;
description: string | null;
priority: number;
priorityLabel: string;
state: { id: string; name: string; type: string } | null;
assignee: { id: string; name: string; email: string } | null;
labels: { nodes: Array<{ id: string; name: string }> };
project: { id: string; name: string } | null;
createdAt: string;
updatedAt: string;
url: string;
}>;
};
};
errors?: Array<{ message?: string }>;
};
if (payload.errors?.length) {
throw new Error(payload.errors.map((e) => e.message ?? "Unknown error").join("; "));
}
const page = payload.data?.issues;
if (!page?.nodes) break;
issues.push(
...page.nodes.map((n) => ({
id: n.id,
identifier: n.identifier,
title: n.title,
description: n.description,
priority: n.priority,
priorityLabel: n.priorityLabel,
state: n.state,
assignee: n.assignee,
labels: n.labels.nodes,
project: n.project,
createdAt: n.createdAt,
updatedAt: n.updatedAt,
url: n.url,
})),
);
if (!page.pageInfo?.hasNextPage) break;
after = page.pageInfo.endCursor;
}
const normalizedLabel = opts.triggerLabel.trim().toLowerCase();
const filtered = issues
.filter((issue) => {
const type = issue.state?.type ?? "";
return type !== "completed" && type !== "canceled";
})
.filter((issue) => opts.allOpen || issue.labels.some((l) => l.name.trim().toLowerCase() === normalizedLabel))
.sort((a, b) => {
const aStarted = a.state?.type === "started" ? 0 : 1;
const bStarted = b.state?.type === "started" ? 0 : 1;
if (aStarted !== bStarted) return aStarted - bStarted;
const aNum = a.identifier.match(/-(\d+)$/)?.[1];
const bNum = b.identifier.match(/-(\d+)$/)?.[1];
return (aNum ? parseInt(aNum, 10) : Infinity) - (bNum ? parseInt(bNum, 10) : Infinity);
});
return opts.limit && opts.limit > 0 ? filtered.slice(0, opts.limit) : filtered;
}
// ---------------------------------------------------------------------------
// ReviewTask component
// ---------------------------------------------------------------------------
type SmithersCtx = Parameters<Parameters<typeof smithers>[0]>[0];
function ReviewTask({ ticketId, issueId, worktreePath, branchName, reviewerName, agent, ctx }: {
ticketId: string;
issueId: string;
worktreePath: string;
branchName: string;
reviewerName: string;
agent: InstanceType<typeof ClaudeCodeAgent | typeof CodexAgent | typeof GeminiAgent>;
ctx: SmithersCtx;
}) {
const latestImpl = ctx.outputs.implement?.[ctx.outputs.implement.length - 1];
return (
<Task id={`review-${reviewerName}-${ticketId.toLowerCase()}`} output={outputs.review} agent={agent} retries={TASK_RETRIES}>
<ReviewPrompt ticketId={ticketId} issueId={issueId} implementation={latestImpl} worktreePath={worktreePath} branchName={branchName} reviewerName={reviewerName} />
</Task>
);
}
// ---------------------------------------------------------------------------
// TicketPipeline component
// ---------------------------------------------------------------------------
function TicketPipeline({ ticket, doneStateId, ctx }: {
ticket: LinearIssue;
doneStateId: string | null;
ctx: SmithersCtx;
}) {
const tid = ticket.identifier.toLowerCase();
const branchName = `smithers/${tid}`;
const worktreePath = `${WORKTREE_DIR}/${tid}`;
const existingResearch = ctx.outputMaybe?.("research", { nodeId: `research-${tid}` });
const existingPlan = ctx.outputMaybe?.("plan", { nodeId: `plan-${tid}` });
const latestClaudeReview = ctx.latest?.("review", `review-claude-${tid}`);
const latestCodexReview = ctx.latest?.("review", `review-codex-${tid}`);
const latestGeminiReview = ctx.latest?.("review", `review-gemini-${tid}`);
const allLgtm =
latestClaudeReview?.verdict === "LGTM" &&
latestCodexReview?.verdict === "LGTM" &&
latestGeminiReview?.verdict === "LGTM";
const latestResearch = existingResearch ?? ctx.outputs.research?.[ctx.outputs.research.length - 1];
const isFrontend = latestResearch?.isFrontend ?? false;
const previousFeedback = [
latestClaudeReview?.verdict === "NEEDS_CHANGES" ? `Claude reviewer: ${latestClaudeReview.comments.join("; ")}` : null,
latestCodexReview?.verdict === "NEEDS_CHANGES" ? `Codex reviewer: ${latestCodexReview.comments.join("; ")}` : null,
latestGeminiReview?.verdict === "NEEDS_CHANGES" ? `Gemini reviewer: ${latestGeminiReview.comments.join("; ")}` : null,
].filter(Boolean).join("\n");
const latestPlan = existingPlan ?? ctx.outputs.plan?.[ctx.outputs.plan.length - 1];
const implementProps = { identifier: ticket.identifier, title: ticket.title, plan: latestPlan, previousFeedback, worktreePath, branchName };
const reviewProps = { ticketId: ticket.identifier, issueId: ticket.id, worktreePath, branchName, ctx };
return (
<Sequence>
<Task id={`research-${tid}`} output={outputs.research} agent={claudeResearcher} retries={TASK_RETRIES} skipIf={!!existingResearch}>
<ResearchPrompt identifier={ticket.identifier} title={ticket.title} description={ticket.description} labels={ticket.labels.map((l) => l.name).join(", ")} issueId={ticket.id} />
</Task>
<Task id={`plan-${tid}`} output={outputs.plan} agent={codexPlanner} retries={TASK_RETRIES} skipIf={!!existingPlan}>
<PlanPrompt identifier={ticket.identifier} title={ticket.title} description={ticket.description} issueId={ticket.id} research={latestResearch} />
</Task>
<Worktree id={`wt-${tid}`} path={worktreePath} branch={branchName}>
<Ralph until={allLgtm} maxIterations={20} onMaxReached="return-last" skipIf={allLgtm}>
<Sequence>
<Branch
if={isFrontend}
then={<Task id={`implement-${tid}`} output={outputs.implement} agent={geminiImplementer} retries={TASK_RETRIES}><ImplementPrompt {...implementProps} /></Task>}
else={<Task id={`implement-${tid}`} output={outputs.implement} agent={codexImplementer} retries={TASK_RETRIES}><ImplementPrompt {...implementProps} /></Task>}
/>
<Parallel>
<ReviewTask reviewerName="claude" agent={claudeReviewer} {...reviewProps} />
<ReviewTask reviewerName="codex" agent={codexReviewer} {...reviewProps} />
<ReviewTask reviewerName="gemini" agent={geminiReviewer} {...reviewProps} />
</Parallel>
</Sequence>
</Ralph>
</Worktree>
<Task id={`merge-${tid}`} output={outputs.linearUpdate}>
{async () => {
if (args.dryRun) {
return { ticketId: ticket.identifier, newStateId: doneStateId ?? "dry-run", commentId: "dry-run", success: true };
}
const { $ } = await import("bun");
$.cwd(process.cwd());
await $`jj squash --from ${branchName} --into main -m ${`${ticket.identifier}: ${ticket.title}`}`;
console.log(` Squashed ${branchName} into main`);
await $`jj workspace forget ${`wt-${tid}`}`.nothrow();
await $`rm -rf ${worktreePath}`.nothrow();
console.log(` Cleaned up worktree ${worktreePath}`);
const linearClient = getLinearClient();
const implementResult = ctx.outputs.implement?.[ctx.outputs.implement.length - 1];
const reviewSummary = [
`Claude: ${latestClaudeReview?.verdict ?? "N/A"}`,
`Codex: ${latestCodexReview?.verdict ?? "N/A"}`,
`Gemini: ${latestGeminiReview?.verdict ?? "N/A"}`,
].join(" | ");
const body = [
`## Smithers Workflow Complete`,
"",
`**Reviews:** ${reviewSummary}`,
`**Branch:** \`${branchName}\` → squashed into main`,
"",
`**Files changed:** ${implementResult?.filesChanged?.join(", ") ?? "none"}`,
"",
`**Summary:** ${implementResult?.summary ?? "No summary"}`,
].join("\n");
const commentResult = await linearClient.createComment({ issueId: ticket.id, body });
const comment = await commentResult.comment;
if (doneStateId) {
await linearClient.updateIssue(ticket.id, { stateId: doneStateId });
}
return { ticketId: ticket.identifier, newStateId: doneStateId ?? "", commentId: comment?.id ?? "", success: true };
}}
</Task>
</Sequence>
);
}
// ---------------------------------------------------------------------------
// Workflow
// ---------------------------------------------------------------------------
const workflow = smithers((ctx) => {
const setup = ctx.latest?.("setup", "setup");
const issues = (setup?.issues ?? []) as LinearIssue[];
return (
<Workflow name="smithers-batch">
<Sequence>
<Task id="setup" output={outputs.setup} skipIf={!!setup}>
{async () => {
const client = getLinearClient();
const teamsResult = await client.teams();
const team = teamsResult.nodes.find((t: any) => t.key === args.team);
if (!team) throw new Error(`Team "${args.team}" not found. Available: ${teamsResult.nodes.map((t: any) => t.key).join(", ")}`);
const statesResult = await (await client.team(team.id)).states();
const statuses: LinearIssueStatus[] = statesResult.nodes.map((s: any) => ({
id: s.id, name: s.name, type: s.type, position: s.position,
}));
const fetchedIssues = await fetchBackfillIssues(team.id, {
allOpen: args.allOpen,
triggerLabel: args.triggerLabel,
limit: args.limit,
});
console.log(`Found ${fetchedIssues.length} issue(s)`);
if (args.dryRun) {
console.log("\n[DRY RUN] Would process:");
for (const issue of fetchedIssues) console.log(` ${issue.identifier}: ${issue.title}`);
return { issues: [], doneStateId: null };
}
await Bun.write(`${WORKTREE_DIR}/.gitkeep`, "");
const inProgressState = statuses.find((s) => s.type === "started" && s.name.toLowerCase().includes("progress"));
for (const issue of fetchedIssues) {
if (inProgressState) await client.updateIssue(issue.id, { stateId: inProgressState.id });
}
return {
issues: fetchedIssues,
doneStateId: statuses.find((s) => s.type === "completed")?.id ?? null,
};
}}
</Task>
<Parallel maxConcurrency={args.concurrency}>
{issues.map((issue) => (
<TicketPipeline key={issue.id} ticket={issue} doneStateId={setup?.doneStateId ?? null} ctx={ctx} />
))}
</Parallel>
</Sequence>
</Workflow>
);
});
// ---------------------------------------------------------------------------
// Run
// ---------------------------------------------------------------------------
const runOpts = { input: { team: args.team }, maxConcurrency: args.concurrency * 5 };
let result = await runWorkflow(workflow, { ...runOpts, resume: true });
if (result.status === "failed" && (result.error as any)?.code === "MISSING_INPUT") {
result = await runWorkflow(workflow, runOpts);
}
console.log(`\nBatch complete: ${result.status}`);
process.exit(result.status === "finished" ? 0 : 1);
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment