Files
evanpelle 168db85be5 claude
2026-05-30 19:49:37 -07:00

623 lines
22 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import Anthropic from "@anthropic-ai/sdk";
import type { AreaLabel, Classification } from "./config";
import {
CLAUDE_MODEL,
DIGEST_LOOKBACK_HOURS,
MAINTAINER_LOGIN,
} from "./config";
import type { Issue, PullRequest } from "./github";
export type Confidence = "low" | "medium" | "high";
export type TriageDecision = {
classification: Classification;
confidence: Confidence;
reasoning: string;
suggested_area?: AreaLabel;
duplicate_of?: number;
clarifying_questions?: string[];
};
export type PRReview = {
summary: string;
risk: "low" | "medium" | "high";
tests_present: "yes" | "no" | "n/a";
scope_match: "matches" | "expanded" | "no_linked_issue";
ai_smell?: "low" | "medium" | "high";
concerns?: string[];
};
export type DigestResult = {
markdown: string;
maintainer_actions?: string[];
};
export type UsageStats = {
input_tokens: number;
output_tokens: number;
};
export function makeAnthropic(apiKey: string): Anthropic {
return new Anthropic({ apiKey });
}
// ---------------------------------------------------------------------------
// Tool schemas — force structured output via tool_choice
// ---------------------------------------------------------------------------
export const TRIAGE_TOOL = {
name: "submit_triage_decision",
description: "Submit your triage decision for this GitHub issue.",
input_schema: {
type: "object" as const,
required: ["classification", "confidence", "reasoning"],
properties: {
classification: {
type: "string",
enum: [
"bug",
"qol-improvement",
"duplicate",
"needs-info",
"feature",
"question",
"support",
"billing",
"security",
"translation",
"uncertain",
],
description: "The category this issue falls into.",
},
confidence: {
type: "string",
enum: ["low", "medium", "high"],
description:
"How confident you are. Auto-close categories require 'high' confidence to take action; below that, the system downgrades to 'uncertain'.",
},
reasoning: {
type: "string",
description:
"One or two sentences explaining the classification. Will be logged for audit.",
},
suggested_area: {
type: "string",
enum: [
"area:client",
"area:core",
"area:server",
"area:map-generator",
"area:other",
],
description:
"For 'bug' or 'qol-improvement' only. Best-guess area of the codebase.",
},
duplicate_of: {
type: "integer",
description:
"For 'duplicate' classification only. The issue number this duplicates. Must be drawn from the provided recent-issues list and must be currently open.",
},
clarifying_questions: {
type: "array",
items: { type: "string" },
description:
"For 'needs-info' classification only. Specific questions to ask the reporter — not generic 'please provide more info'. Each item is one question.",
},
},
},
} as const;
export const PR_REVIEW_TOOL = {
name: "submit_pr_review",
description: "Submit a quick-review summary of a GitHub pull request.",
input_schema: {
type: "object" as const,
required: ["summary", "risk", "tests_present", "scope_match"],
properties: {
summary: {
type: "string",
description:
"One sentence (max ~80 chars) describing what this PR does.",
},
risk: {
type: "string",
enum: ["low", "medium", "high"],
description:
"Risk assessment based on what code is touched and how. src/core changes are higher risk; UI-only changes are lower.",
},
tests_present: {
type: "string",
enum: ["yes", "no", "n/a"],
description:
"For PRs touching src/core, tests are required. 'n/a' if no src/core changes.",
},
scope_match: {
type: "string",
enum: ["matches", "expanded", "no_linked_issue"],
description:
"Does the PR match the scope of its linked issue? 'expanded' if it does extra things beyond what the issue asked for.",
},
ai_smell: {
type: "string",
enum: ["low", "medium", "high"],
description:
"Likelihood this is unsupervised LLM output. Look for: invented APIs, inconsistent style, over-commented obvious lines, defensive code around impossible cases.",
},
concerns: {
type: "array",
items: { type: "string" },
description:
"Specific things the maintainer should look at first. Empty if none.",
},
},
},
} as const;
export const DIGEST_TOOL = {
name: "submit_digest",
description: "Submit the daily triage digest for the maintainer.",
input_schema: {
type: "object" as const,
required: ["markdown"],
properties: {
markdown: {
type: "string",
description:
"The full digest as GitHub-flavored markdown. Follow the format specified in the system prompt. Omit empty sections.",
},
maintainer_actions: {
type: "array",
items: { type: "string" },
description:
"Concrete suggested actions for the maintainer this morning (e.g., 'Milestone #123 to backlog').",
},
},
},
} as const;
// ---------------------------------------------------------------------------
// System prompts
// ---------------------------------------------------------------------------
export const TRIAGE_SYSTEM_PROMPT = `You are the triage assistant for OpenFront.io, a browser-based real-time strategy game written in TypeScript. The codebase has four main components:
- \`src/client/\` — rendering (WebGL), UI (Lit web components + Tailwind), WebSocket comms.
- \`src/core/\` — deterministic game simulation (no external deps, runs in a Web Worker).
- \`src/server/\` — Node.js/Express/ws coordinator.
- \`map-generator/\` — Go program that generates map binaries.
Your job is to classify an incoming issue into exactly one category and report your confidence. The system applies actions automatically based on your decision. Be conservative — when uncertain, return \`uncertain\`. False-closing a real bug is worse than leaving an uncertain issue open for the maintainer to look at.
## Classification taxonomy
**bug** — Reproducible defect in existing functionality. Crashes, wrong rendering, server errors, broken game logic. The game does X but should do Y.
**qol-improvement** — Small-scope work reasonably completable by one contributor in under a week. Includes hotkey additions, confirmation dialogs, UI tweaks, minor behavior changes, **small new features that don't require new game systems**, sound/visual polish, and performance improvements to existing code paths. The line is *scope/effort*, not "is it new functionality."
**duplicate** — Same issue (semantically, not lexically) exists in the recent open-issues context. You MUST cite the duplicate issue number. Do not suggest duplicates of closed issues — they're parked or rejected.
**needs-info** — Plausibly a bug or QoL, but lacks specifics needed to act: no repro steps, no version (\`gitCommit\`), no expected-vs-actual. Provide *specific* clarifying questions, not generic "please provide more info."
**feature** — Substantial new system. Multi-week scope. Touches multiple subsystems. Examples: new unit types, new game modes, achievement systems, alternate currencies, cosmetic systems, new map *types* (not "add one more map"), new alliance mechanics, anything needing new art assets or new networked state.
**question** — Asking about the codebase, contributing, building from source, architecture.
**support** — Player asking for help playing: can't connect, account issues, reporting players, troubleshooting their local environment.
**billing** — Any mention of money: purchases, subscriptions, refunds, premium, payment failures. Even if there's also a technical defect, classify as billing — support has billing access.
**security** — Vulnerability reports, exploit disclosures, anything that shouldn't be public. The system will NOT auto-close these or post a comment — it just routes to \`claude-uncertain\` so the maintainer can handle them discreetly (closing wouldn't make the issue private anyway).
**translation** — Localization issues, language errors, new-language requests.
**uncertain** — You can't confidently place this in any other category. Default to this.
## Tie-breaking
- Bug + billing → **billing.**
- Bug + support: specific reproducible defect → **bug**, vague "doesn't work for me" → **support**, doubt → **bug**.
- Feature vs. QoL: new assets/systems → **feature**, tweak to existing flow → **qol-improvement**, doubt → **uncertain** (do NOT default to feature; that auto-closes).
## Examples
- "Game crashes when I right-click in spectator mode after 5 minutes" → **bug** (reproducible defect, specific trigger), area:client, confidence: high.
- "Could you add a hotkey to toggle the chat?" → **qol-improvement** (small UI tweak), area:client, confidence: high.
- "Game is buggy" with no body → **needs-info**, clarifying_questions: ["Which specific bug?", "Steps to reproduce?", "Which browser and OS?"], confidence: high.
- "Add an espionage mechanic with hidden units" → **feature** (new game system), confidence: high.
- "Refund my purchase, it doesn't work" → **billing**, confidence: high.
- "How do I run the dev server?" → **question**, confidence: high.
- "Can't connect to any game" → **support** unless concrete repro steps are given.
- "I think there's an XSS in the chat" → **security**, confidence: high.
- Issue text identical in shape to recent open #1234 → **duplicate**, duplicate_of: 1234.
- Something ambiguous, e.g. an issue with rich but unclear repro that could be a bug or could be PEBKAC → **uncertain** (keep it open for the maintainer).
## Confidence
Auto-close categories (feature, question, support, billing, translation) require \`confidence: high\` for the system to act on them. Anything lower will be downgraded to \`uncertain\` and stay open. So:
- For an auto-close category, use \`high\` only if you'd defend the decision to the maintainer.
- For label-only categories (bug, qol-improvement, duplicate, needs-info, security), any confidence is fine; the consequences are small and reversible.
## Hard rules
- NEVER suggest setting a milestone. Milestones are maintainer-only.
- NEVER suggest \`approved\` or \`not-approved\` labels. Layer A manages those automatically based on milestone state.
- NEVER suggest assigning anyone.
- NEVER reopen closed issues; never reference them as duplicates.
- Being skeptical and returning \`uncertain\` is better than miscategorizing.
Call the \`submit_triage_decision\` tool exactly once.`;
export const PR_REVIEW_SYSTEM_PROMPT = `You are a code-review assistant doing a *fast triage pass* on a pull request for OpenFront.io, a browser-based RTS written in TypeScript.
Your output is one row in the maintainer's daily digest. It should:
- Summarize the PR in one sentence (max ~80 chars).
- Assess risk based on what code is touched: \`src/core\` (deterministic simulation) is highest risk, \`src/server\` medium, \`src/client\` lower, docs/tests/config typically low.
- Report whether tests are present. **Tests are required for any change in \`src/core\`.** Use 'n/a' only when no \`src/core\` files are touched.
- Compare against the linked issue's scope. 'expanded' means the PR does noticeably more than the issue asked for (often a red flag). 'no_linked_issue' if the PR doesn't reference an issue.
- Optionally flag AI smells: invented APIs, inconsistent style, overly defensive code for impossible cases, dense comments on obvious lines.
- Note specific concerns the maintainer should look at first, if any.
Be terse. The output goes into a maintainer digest scanned at a glance. Call the \`submit_pr_review\` tool exactly once.`;
export const DIGEST_SYSTEM_PROMPT = `You are writing a daily triage digest for ${MAINTAINER_LOGIN} (Evan), the maintainer of OpenFront.io.
The digest is a brief morning briefing. It tells Evan what needs his attention today.
## Required structure (markdown)
\`\`\`markdown
# Daily Triage — YYYY-MM-DD
## 🟢 Newly classified by Claude (N)
- #123 — short summary · suggested action
- ...
## 🟡 Needs your judgment (M)
- #125 — Claude flagged 'claude-uncertain' because [reason]
## 🔴 Auto-closing in <24h (K)
- #126 — \`stale\`, milestone or let close
## 🟠 Awaiting your milestone (Q)
- #45 [bug, area:client] — 21d — Crash on right-click
- #76 [qol-improvement] — 12d — Add chat hotkey
- ...
## 🔵 PRs needing review (P)
- #220 by @alice — fixes alliance UI bug · src/client only · tests ✓
- ...
## Patterns
[Only if you noticed something specific. Otherwise omit this section entirely.]
## Recommended actions
- Milestone #123 as backlog
- ...
\`\`\`
## Rules
- Target length: 200600 words. Should fit in one or two screenfuls.
- Omit any section that has no entries. Do not write "No items" rows.
- Direct address: "Evan, today you have..." is fine if it's natural; don't force it.
- For each PR, surface the pre-computed quick review: risk, tests, scope match. Don't speculate beyond what's given.
- **Awaiting your milestone**: list ALL items from the input verbatim. This is the full backlog of un-milestoned issues. Don't truncate or sample — Evan wants to see every one. The bracketed tags (e.g. \`[bug, area:client]\`) and age (e.g. \`21d\`) come straight from the input; pass them through unchanged.
- **Patterns**: only mention if the input genuinely shows a cluster (e.g., 4+ new issues mentioning the same map or feature). Do NOT invent patterns. Omit the section if nothing notable.
- **Recommended actions**: 25 concrete items maximum. Each is something Evan can do today in <5 minutes.
- Use the issue/PR data exactly as provided. Don't hallucinate numbers, authors, or labels.
Call the \`submit_digest\` tool exactly once with the full markdown.`;
// ---------------------------------------------------------------------------
// Input formatters
// ---------------------------------------------------------------------------
const MAX_BODY_CHARS = 4000;
const MAX_DIFF_CHARS = 20000;
const RECENT_ISSUE_SUMMARY_CHARS = 100;
function truncate(s: string, max: number): string {
if (s.length <= max) return s;
return s.slice(0, max) + `\n\n…[truncated; ${s.length - max} chars omitted]`;
}
export function formatIssueForTriage(
issue: Issue,
recentOpenIssues: Issue[],
): string {
const body =
issue.body.trim() === ""
? "_(empty body)_"
: truncate(issue.body, MAX_BODY_CHARS);
const labels = issue.labels.length > 0 ? issue.labels.join(", ") : "(none)";
const recent = recentOpenIssues
.filter((r) => r.number !== issue.number)
.map((r) => {
const snippet = r.body
.replace(/\s+/g, " ")
.slice(0, RECENT_ISSUE_SUMMARY_CHARS)
.trim();
return `- #${r.number}: ${r.title}${snippet}`;
})
.join("\n");
return `## Issue to classify
- Number: #${issue.number}
- Author: @${issue.user?.login ?? "unknown"}
- Author association: ${issue.author_association}
- Created: ${issue.created_at}
- Existing labels: ${labels}
### Title
${issue.title}
### Body
${body}
---
## Recent open issues (for duplicate detection only, NOT for classifying this issue)
${recent || "_(no other open issues)_"}
---
Classify the issue above. Call \`submit_triage_decision\` exactly once.`;
}
export function formatPRForReview(
pr: PullRequest,
diff: string,
linkedIssue: Issue | null,
): string {
const description =
pr.body.trim() === ""
? "_(empty description)_"
: truncate(pr.body, MAX_BODY_CHARS);
const linkedIssueBlock = linkedIssue
? `## Linked issue #${linkedIssue.number}: ${linkedIssue.title}
${truncate(linkedIssue.body || "_(empty body)_", 1500)}`
: "_(no linked issue detected)_";
return `## Pull request to review
- Number: #${pr.number}
- Author: @${pr.user?.login ?? "unknown"} (association: ${pr.author_association})
- Title: ${pr.title}
- Created: ${pr.created_at}
### Description
${description}
${linkedIssueBlock}
## Diff
\`\`\`diff
${truncate(diff, MAX_DIFF_CHARS)}
\`\`\`
Review the PR above. Call \`submit_pr_review\` exactly once.`;
}
export type DigestInputs = {
today: string;
triageReadyInWindow: {
number: number;
title: string;
author: string;
area: string | null;
}[];
needsMaintainerTriage: {
number: number;
title: string;
reasoning?: string;
}[];
needsInfoCount: number;
staleClosingSoon: { number: number; title: string }[];
awaitingMilestone: {
number: number;
title: string;
ageDays: number;
primaryLabel: string | null;
area: string | null;
}[];
prReviews: {
pr: { number: number; title: string; author: string };
review: PRReview;
}[];
triageSummary: { processed: number; closed: number; kept: number };
};
export function formatDigestInputs(inputs: DigestInputs): string {
const triageReady =
inputs.triageReadyInWindow.length === 0
? "_(none)_"
: inputs.triageReadyInWindow
.map(
(i) =>
`- #${i.number} by @${i.author}${i.area ? ` [${i.area}]` : ""}: ${i.title}`,
)
.join("\n");
const needsTriage =
inputs.needsMaintainerTriage.length === 0
? "_(none)_"
: inputs.needsMaintainerTriage
.map(
(i) =>
`- #${i.number}: ${i.title}${i.reasoning ? ` — Claude's reasoning: ${i.reasoning}` : ""}`,
)
.join("\n");
const stale =
inputs.staleClosingSoon.length === 0
? "_(none)_"
: inputs.staleClosingSoon
.map((i) => `- #${i.number}: ${i.title}`)
.join("\n");
const awaitingMilestone =
inputs.awaitingMilestone.length === 0
? "_(none)_"
: inputs.awaitingMilestone
.map((i) => {
const tags = [i.primaryLabel, i.area].filter(Boolean).join(", ");
const tagStr = tags ? ` [${tags}]` : "";
return `- #${i.number}${tagStr}${i.ageDays}d — ${i.title}`;
})
.join("\n");
const prs =
inputs.prReviews.length === 0
? "_(none)_"
: inputs.prReviews
.map(({ pr, review }) => {
const concerns =
review.concerns && review.concerns.length > 0
? ` · concerns: ${review.concerns.join("; ")}`
: "";
const ai =
review.ai_smell && review.ai_smell !== "low"
? ` · ai-smell: ${review.ai_smell}`
: "";
return `- #${pr.number} by @${pr.author}: ${pr.title}
summary: ${review.summary}
risk: ${review.risk} · tests: ${review.tests_present} · scope: ${review.scope_match}${ai}${concerns}`;
})
.join("\n");
return `Today: ${inputs.today}
## Triage pass summary (last 24h)
- Processed: ${inputs.triageSummary.processed}
- Auto-closed: ${inputs.triageSummary.closed}
- Kept open: ${inputs.triageSummary.kept}
## Newly classified by Claude (last ${DIGEST_LOOKBACK_HOURS}h, label: claude-approved)
${triageReady}
## Needs maintainer triage (label: claude-uncertain)
${needsTriage}
## Needs info (label: needs-info)
Count: ${inputs.needsInfoCount}
## Stale, closing soon (label: stale; Layer A will close in <24h unless milestoned)
${stale}
## Awaiting your milestone (label: not-approved; full open backlog, sorted oldest first)
${awaitingMilestone}
## Open PRs awaiting maintainer review (any author)
${prs}
Compose the digest now. Follow the format in the system prompt. Omit empty sections.`;
}
// ---------------------------------------------------------------------------
// API call helpers
// ---------------------------------------------------------------------------
type ToolUseBlock = {
type: "tool_use";
name: string;
input: unknown;
};
function findToolUse(
content: Anthropic.Messages.ContentBlock[],
expectedName: string,
): ToolUseBlock {
for (const block of content) {
if (block.type === "tool_use") {
const tu = block as unknown as ToolUseBlock;
if (tu.name !== expectedName) {
throw new Error(
`Claude returned tool_use for unexpected tool "${tu.name}" (expected "${expectedName}")`,
);
}
return tu;
}
}
throw new Error(
`Claude response had no tool_use block (expected "${expectedName}")`,
);
}
export type CallResult<T> = { value: T; usage: UsageStats };
async function callClaudeWithTool<T>(
anthropic: Anthropic,
args: {
system: string;
userContent: string;
tool: {
name: string;
description: string;
input_schema: { type: "object" } & Record<string, unknown>;
};
maxTokens: number;
},
): Promise<CallResult<T>> {
const response = await anthropic.messages.create({
model: CLAUDE_MODEL,
max_tokens: args.maxTokens,
system: args.system,
messages: [{ role: "user", content: args.userContent }],
tools: [args.tool],
tool_choice: { type: "tool", name: args.tool.name },
});
const tu = findToolUse(response.content, args.tool.name);
return {
value: tu.input as T,
usage: {
input_tokens: response.usage.input_tokens,
output_tokens: response.usage.output_tokens,
},
};
}
export async function classifyIssue(
anthropic: Anthropic,
issue: Issue,
recentOpenIssues: Issue[],
): Promise<CallResult<TriageDecision>> {
return callClaudeWithTool<TriageDecision>(anthropic, {
system: TRIAGE_SYSTEM_PROMPT,
userContent: formatIssueForTriage(issue, recentOpenIssues),
tool: TRIAGE_TOOL,
maxTokens: 1024,
});
}
export async function reviewPR(
anthropic: Anthropic,
pr: PullRequest,
diff: string,
linkedIssue: Issue | null,
): Promise<CallResult<PRReview>> {
return callClaudeWithTool<PRReview>(anthropic, {
system: PR_REVIEW_SYSTEM_PROMPT,
userContent: formatPRForReview(pr, diff, linkedIssue),
tool: PR_REVIEW_TOOL,
maxTokens: 800,
});
}
export async function composeDigest(
anthropic: Anthropic,
inputs: DigestInputs,
): Promise<CallResult<DigestResult>> {
return callClaudeWithTool<DigestResult>(anthropic, {
system: DIGEST_SYSTEM_PROMPT,
userContent: formatDigestInputs(inputs),
tool: DIGEST_TOOL,
maxTokens: 2000,
});
}