diff --git a/apps/desktop/src/main/ipc/register-github-handlers.ts b/apps/desktop/src/main/ipc/register-github-handlers.ts index 8c420413..f22b19e1 100644 --- a/apps/desktop/src/main/ipc/register-github-handlers.ts +++ b/apps/desktop/src/main/ipc/register-github-handlers.ts @@ -1,6 +1,12 @@ import fs from 'node:fs'; import path from 'node:path'; -import { enhancePrdDraft, fetchProjectPriorities, GhCli } from '@shipcode/agents'; +import { + enhancePrdDraft, + fetchProjectPriorities, + GhCli, + type IssueTriageRecommendation, + triageGitHubIssues, +} from '@shipcode/agents'; import type { ExecutorModel, GitHubIssueCacheRecord, @@ -88,6 +94,23 @@ function syncOpenIssueState( return queries.githubIssues.getByNumber(issue.projectId, issue.issueNumber); } +function mergeTriageLabels( + currentLabels: string[], + recommendation: IssueTriageRecommendation, +): string[] { + const next = currentLabels.filter( + (label) => + !label.startsWith('agent:') && + !label.startsWith('complexity:') && + !label.startsWith('blast:'), + ); + const labels = [...recommendation.suggestedLabels]; + if (recommendation.suggestedAgent && !labels.some((label) => label.startsWith('agent:'))) { + labels.push(`agent:${recommendation.suggestedAgent}`); + } + return Array.from(new Set([...next, ...labels])); +} + export function registerGitHubHandlers({ ipcMain, mainWindow, @@ -274,6 +297,97 @@ export function registerGitHubHandlers({ }, ); + ipcMain.handle('github:triage-issues', async (_event, { projectId }: { projectId: string }) => { + const project = queries.projects.getById(projectId); + if (!project) throw new Error(`Project ${projectId} not found`); + if (!fs.existsSync(project.path)) { + throw new Error( + `Project path no longer exists: ${project.path}. Re-add the repository from a valid path.`, + ); + } + + const settings = queries.settings.get(); + const ghCli = new GhCli(project.path); + const candidates = queries.githubIssues + .list(projectId) + .filter( + (issue) => + issue.state === 'open' && + issue.pipelineStatus === ISSUE_PIPELINE_STATUS.todo && + !issue.threadId && + !issue.isQuickMode && + isRealGithubIssueNumber(issue.issueNumber), + ); + + if (candidates.length === 0) { + return { + provider: settings.triageModel, + modelId: settings.triageModelId, + resolvedModel: settings.triageModelId, + consideredCount: 0, + appliedCount: 0, + skippedCount: 0, + threshold: settings.triageAutoApplyThreshold, + issues: [], + }; + } + + await ghCli.ensureLabels(SHIPCODE_DEFAULT_LABELS); + const result = await triageGitHubIssues({ + cwd: project.path, + issues: candidates, + settings, + apiKey: process.env.OPENROUTER_API_KEY, + }); + const candidatesByNumber = new Map(candidates.map((issue) => [issue.issueNumber, issue])); + const threshold = settings.triageAutoApplyThreshold; + let appliedCount = 0; + const summaries = []; + + for (const recommendation of result.recommendations) { + const issue = candidatesByNumber.get(recommendation.issueNumber); + if (!issue) continue; + const applied = recommendation.confidence >= threshold; + if (applied) { + const nextLabels = mergeTriageLabels(issue.labels, recommendation); + await ghCli.syncIssueLabels(issue.issueNumber, nextLabels, { removeAgentLabels: true }); + const refreshedIssue = await ghCli.getIssue(issue.issueNumber); + queries.githubIssues.upsert({ + projectId, + issueNumber: refreshedIssue.number, + title: refreshedIssue.title, + body: refreshedIssue.body, + labels: refreshedIssue.labels, + assignee: refreshedIssue.assignee, + state: refreshedIssue.state, + }); + appliedCount += 1; + } + summaries.push({ + issueNumber: recommendation.issueNumber, + confidence: recommendation.confidence, + applied, + suggestedLabels: recommendation.suggestedLabels, + suggestedAgent: recommendation.suggestedAgent, + shouldStart: recommendation.shouldStart, + needsHuman: recommendation.needsHuman, + rationale: recommendation.rationale, + }); + } + + sendGithubIssuesUpdated(mainWindow, queries, projectId); + return { + provider: result.provider, + modelId: result.modelId, + resolvedModel: result.resolvedModel, + consideredCount: candidates.length, + appliedCount, + skippedCount: Math.max(0, summaries.length - appliedCount), + threshold, + issues: summaries, + }; + }); + ipcMain.handle( 'github:archive-issue', async (_event, { projectId, issueNumber }: { projectId: string; issueNumber: number }) => { diff --git a/apps/desktop/src/renderer/components/ThreadPanel.tsx b/apps/desktop/src/renderer/components/ThreadPanel.tsx index 2c7fa6b9..9f3a288e 100644 --- a/apps/desktop/src/renderer/components/ThreadPanel.tsx +++ b/apps/desktop/src/renderer/components/ThreadPanel.tsx @@ -1,6 +1,7 @@ import { type AppSettings, type GitHubIssueCacheRecord, + type GitHubIssueTriageResult, githubRepoUrl, ISSUE_PIPELINE_STATUS, type IssuePipelineStatus, @@ -154,6 +155,28 @@ export function ThreadPanel() { }, }); + const triageIssues = useMutation({ + mutationFn: (projectId: string) => + window.shipcode.invoke('github:triage-issues', { projectId }), + onSuccess: (result, projectId) => { + queryClient.invalidateQueries({ queryKey: ['github-issues', projectId] }); + setArchiveFeedback({ + tone: result.appliedCount > 0 ? 'success' : 'pending', + message: + result.consideredCount === 0 + ? 'No Todo issues need triage.' + : `Triaged ${result.consideredCount} issue${result.consideredCount === 1 ? '' : 's'}; applied ${result.appliedCount}.`, + }); + }, + onError: (err) => { + setArchiveFeedback({ + tone: 'error', + message: `Issue triage failed: ${err instanceof Error ? err.message : String(err)}`, + }); + log.error('[threadpanel] triage-issues failed', { err }); + }, + }); + useEffect(() => { if (!archiveFeedback || archiveFeedback.tone === 'pending') return; const id = setTimeout(() => setArchiveFeedback(null), 5000); @@ -410,6 +433,8 @@ export function ThreadPanel() { } selectedIssueNumber={selectedBoardIssueNumber} onRefresh={() => activeProjectId && refreshIssues.mutate(activeProjectId)} + onTriageIssues={() => activeProjectId && triageIssues.mutate(activeProjectId)} + triagingIssues={triageIssues.isPending} baseBranch={project?.defaultBranch} branches={branches} refreshingBranches={isRefreshingBranches} diff --git a/apps/desktop/src/renderer/components/settings-panel/PipelineSettingsSection.tsx b/apps/desktop/src/renderer/components/settings-panel/PipelineSettingsSection.tsx index 4d2c6256..4f653726 100644 --- a/apps/desktop/src/renderer/components/settings-panel/PipelineSettingsSection.tsx +++ b/apps/desktop/src/renderer/components/settings-panel/PipelineSettingsSection.tsx @@ -67,6 +67,21 @@ export function PipelineSettingsSection({ prdRewriteProvider, prdRewriteModelValue, ); + const triageModelOptions = getModelOptions(settings.triageModel, integrationStatus); + const triageKnownModelValues = new Set(triageModelOptions.map((option) => option.value)); + const triageEffortResolution = resolveProviderReasoningEffort( + settings.triageModel, + settings.triageReasoningEffort, + settings.triageModelId, + ); + const triageSupportedEfforts = + settings.triageModel === 'openrouter' + ? (['none', 'low', 'medium', 'high'] as const) + : getCapabilitySupportedReasoningEfforts( + integrationStatus, + settings.triageModel, + settings.triageModelId, + ); const normalizeEffort = ( provider: ExecutorModel, effort: AppSettings['plannerReasoningEffort'], @@ -409,6 +424,122 @@ export function PipelineSettingsSection({ +
+
+
Issue triage
+
+ Board review model for classifying Todo issues and applying high-confidence + labels. +
+
+ + + + + + + + + + + { + const value = Number(event.target.value); + if (value >= 0 && value <= 1) onUpdate({ triageAutoApplyThreshold: value }); + }} + /> + +
+ { + async syncIssueLabels( + issueNumber: number, + labels: string[], + options?: { removeAgentLabels?: boolean }, + ): Promise { const next = await this.filterExistingLabels(labels); let current: string[] = []; @@ -183,9 +187,12 @@ export class GhCli { current = []; } - const toRemove = current.filter( - (label) => this.isManagedPrdLabel(label) && !next.includes(label), - ); + const toRemove = current.filter((label) => { + const managed = + this.isManagedPrdLabel(label) || + (!!options?.removeAgentLabels && label.startsWith('agent:')); + return managed && !next.includes(label); + }); const toAdd = next.filter((label) => !current.includes(label)); for (const label of toRemove) { diff --git a/packages/agents/src/github/issue-triage.test.ts b/packages/agents/src/github/issue-triage.test.ts new file mode 100644 index 00000000..eb871adc --- /dev/null +++ b/packages/agents/src/github/issue-triage.test.ts @@ -0,0 +1,76 @@ +import { describe, expect, it } from 'vitest'; +import { buildTriagePrompt, extractTriageRecommendations } from './issue-triage'; + +describe('issue triage', () => { + it('builds a prompt with compact issue data and the output contract', () => { + const prompt = buildTriagePrompt([ + { + id: 'i1', + projectId: 'p1', + issueNumber: 12, + title: 'Fix login crash', + body: 'Crashes on submit', + labels: ['bug'], + assignee: null, + state: 'open', + pipelineStatus: 'todo', + threadId: null, + claimedAt: null, + claimedBy: null, + lastPhaseUpdate: null, + lastStatusLabel: null, + plannerModelOverride: null, + reviewerModelOverride: null, + executorModelOverride: null, + verifierModelOverride: null, + plannerModelIdOverride: null, + reviewerModelIdOverride: null, + executorModelIdOverride: null, + verifierModelIdOverride: null, + plannerReasoningEffortOverride: null, + reviewerReasoningEffortOverride: null, + executorReasoningEffortOverride: null, + verifierReasoningEffortOverride: null, + revisionCountOverride: null, + requireApprovalOverride: null, + linkedPrNumber: null, + linkedPrUrl: null, + linkedPrIsDraft: false, + ciBlocked: false, + failingChecks: [], + unresolvedReviewComments: [], + unresolvedReviewCommentCount: 0, + prLastSyncAt: null, + fetchedAt: '2026-01-01T00:00:00.000Z', + priorityRank: null, + priorityRaw: null, + priorityFetchedAt: null, + isQuickMode: false, + }, + ]); + + expect(prompt).toContain('"number": 12'); + expect(prompt).toContain('```shipcode-issue-triage'); + expect(prompt).toContain('agent:codex'); + }); + + it('extracts and normalizes triage recommendations', () => { + const result = extractTriageRecommendations(` +\`\`\`shipcode-issue-triage +{"issues":[{"issueNumber":12,"confidence":2,"suggestedAgent":"codex","suggestedLabels":["agent:codex","not-real"],"shouldStart":true,"needsHuman":false,"rationale":"Ready"}]} +\`\`\` +`); + + expect(result).toEqual([ + { + issueNumber: 12, + confidence: 1, + suggestedAgent: 'codex', + suggestedLabels: ['agent:codex'], + shouldStart: true, + needsHuman: false, + rationale: 'Ready', + }, + ]); + }); +}); diff --git a/packages/agents/src/github/issue-triage.ts b/packages/agents/src/github/issue-triage.ts new file mode 100644 index 00000000..455c41a8 --- /dev/null +++ b/packages/agents/src/github/issue-triage.ts @@ -0,0 +1,312 @@ +import { spawn } from 'node:child_process'; +import type { + AppSettings, + ExecutorModel, + GitHubIssueCacheRecord, + ReasoningEffort, +} from '@shipcode/shared'; +import { extractCliFailureMessage, formatCliSpawnFailure } from '../cli-error'; +import { shellExecEnv } from '../health-check'; +import { OpenRouterClient, OpenRouterError } from '../providers/openrouter-http'; +import { + mapReasoningEffortToClaudeThinkingTokens, + mapReasoningEffortToCodex, + normalizeOpenRouterReasoningEffort, +} from '../providers/reasoning'; + +const TRIAGE_FENCE_TAG = 'shipcode-issue-triage'; +const TRIAGE_TIMEOUT_MS = 120_000; +const TRIAGE_LABELS = [ + 'agent:claude', + 'agent:codex', + 'agent:openrouter', + 'agent:openrouter/auto', + 'agent:openrouter/free', + 'bug', + 'enhancement', + 'deferred', + 'complexity:low', + 'complexity:medium', + 'complexity:high', + 'blast:contained', + 'blast:cross-package', + 'blast:cross-app', + 'blast:infra', +] as const; + +export interface IssueTriageRecommendation { + issueNumber: number; + confidence: number; + suggestedAgent: 'claude' | 'codex' | 'openrouter' | null; + suggestedLabels: string[]; + shouldStart: boolean; + needsHuman: boolean; + rationale: string; +} + +export interface IssueTriageRunResult { + provider: ExecutorModel; + modelId: string | null; + resolvedModel: string | null; + recommendations: IssueTriageRecommendation[]; +} + +interface TriageEnvelope { + issues?: unknown; +} + +export async function triageGitHubIssues(opts: { + cwd: string; + issues: GitHubIssueCacheRecord[]; + settings: Pick< + AppSettings, + 'triageModel' | 'triageModelId' | 'triageReasoningEffort' | 'openrouterDefaultPaidModel' + >; + apiKey?: string; + createOpenRouterClient?: (apiKey: string) => OpenRouterClient; +}): Promise { + const provider = opts.settings.triageModel; + const modelId = opts.settings.triageModelId; + const prompt = buildTriagePrompt(opts.issues); + + if (provider === 'openrouter') { + const apiKey = opts.apiKey; + if (!apiKey) throw new Error('OPENROUTER_API_KEY is not set'); + const client = opts.createOpenRouterClient?.(apiKey) ?? new OpenRouterClient({ apiKey }); + const model = modelId || opts.settings.openrouterDefaultPaidModel; + const controller = new AbortController(); + try { + const result = await client.chat( + { + model, + messages: [ + { + role: 'system', + content: + 'You classify GitHub issues for an autonomous coding pipeline. Return only the requested fenced JSON block.', + }, + { role: 'user', content: prompt }, + ], + stream: false, + include_reasoning: opts.settings.triageReasoningEffort !== 'none', + reasoning: { + effort: normalizeOpenRouterReasoningEffort(opts.settings.triageReasoningEffort, model), + }, + }, + controller.signal, + ); + return { + provider, + modelId, + resolvedModel: result.model ?? model, + recommendations: extractTriageRecommendations(result.content), + }; + } catch (err) { + if (err instanceof OpenRouterError) throw new Error(err.message); + throw err; + } + } + + const text = await runCliTriage({ + provider, + prompt, + cwd: opts.cwd, + modelId, + reasoningEffort: opts.settings.triageReasoningEffort, + }); + return { + provider, + modelId, + resolvedModel: modelId ?? provider, + recommendations: extractTriageRecommendations(text), + }; +} + +export function buildTriagePrompt(issues: GitHubIssueCacheRecord[]): string { + const compactIssues = issues.map((issue) => ({ + number: issue.issueNumber, + title: issue.title, + body: (issue.body ?? '').slice(0, 6000), + labels: issue.labels, + priority: issue.priorityRaw ?? issue.priorityRank, + })); + + return `Review these GitHub issues for ShipCode's autonomous issue-to-PR pipeline. + +Classify each issue using only this label allowlist: +${TRIAGE_LABELS.map((label) => `- ${label}`).join('\n')} + +Guidance: +- Use agent:claude for broad implementation work, multi-file product work, or when local Claude Code is the best default. +- Use agent:codex for focused code edits, tests, TypeScript/React work, or when OpenAI/Codex is a good fit. +- Use agent:openrouter/auto when the issue is suitable for low-cost routing and not high-risk. +- Mark needsHuman=true when the issue lacks acceptance criteria, asks an ambiguous product decision, or conflicts with existing labels. +- Mark shouldStart=true only when the issue is specific enough to hand to the pipeline now. +- Use confidence 0..1. Be conservative; below 0.85 will not be auto-applied by default. + +Issues: +${JSON.stringify(compactIssues, null, 2)} + +Output exactly one fenced block: +\`\`\`${TRIAGE_FENCE_TAG} +{"issues":[{"issueNumber":123,"confidence":0.92,"suggestedAgent":"codex","suggestedLabels":["agent:codex","bug","complexity:low","blast:contained"],"shouldStart":true,"needsHuman":false,"rationale":"Specific bug with clear acceptance criteria."}]} +\`\`\` +`; +} + +function runCliTriage(opts: { + provider: Extract; + prompt: string; + cwd: string; + modelId: string | null; + reasoningEffort: ReasoningEffort; +}): Promise { + return new Promise((resolve, reject) => { + const label = opts.provider === 'claude' ? 'Claude CLI' : 'Codex CLI'; + const args = + opts.provider === 'claude' + ? [ + '-p', + ...(opts.modelId ? ['--model', opts.modelId] : []), + '--output-format', + 'json', + '--max-turns', + '1', + ...(() => { + const thinkingTokens = mapReasoningEffortToClaudeThinkingTokens( + opts.reasoningEffort, + opts.modelId, + ); + return thinkingTokens === null + ? [] + : (['--max-thinking-tokens', String(thinkingTokens)] as string[]); + })(), + '--dangerously-skip-permissions', + '--disallowedTools', + 'Edit,Write,Bash,NotebookEdit,Read,Glob,Grep,Task,WebSearch,WebFetch', + ] + : [ + '-a', + 'never', + ...(opts.modelId ? ['-m', opts.modelId] : []), + '-c', + `model_reasoning_effort=${mapReasoningEffortToCodex( + opts.reasoningEffort, + opts.modelId, + )}`, + 'exec', + '-', + '--sandbox', + 'read-only', + ]; + + const proc = spawn(opts.provider, args, { + cwd: opts.cwd, + stdio: ['pipe', 'pipe', 'pipe'], + env: shellExecEnv(), + }); + let stdout = ''; + let stderr = ''; + const timer = setTimeout(() => { + proc.kill('SIGTERM'); + reject(new Error(`${label} timed out after ${TRIAGE_TIMEOUT_MS}ms`)); + }, TRIAGE_TIMEOUT_MS); + + proc.stdout.on('data', (chunk) => { + stdout += chunk; + }); + proc.stderr.on('data', (chunk) => { + stderr += chunk; + }); + proc.on('error', (err) => { + clearTimeout(timer); + reject(new Error(formatCliSpawnFailure(label, err.message))); + }); + proc.on('close', (code) => { + clearTimeout(timer); + if (code !== 0) { + reject(new Error(`${label} exited ${code}: ${extractCliFailureMessage(stdout, stderr)}`)); + return; + } + resolve(unwrapClaudeJson(stdout)); + }); + proc.stdin.write(opts.prompt); + proc.stdin.end(); + }); +} + +function unwrapClaudeJson(stdout: string): string { + try { + const parsed = JSON.parse(stdout) as { result?: unknown }; + if (typeof parsed.result === 'string') return parsed.result; + } catch { + // raw text + } + return stdout; +} + +export function extractTriageRecommendations(text: string): IssueTriageRecommendation[] { + const captured = extractFencedJson(text, TRIAGE_FENCE_TAG); + let parsed: TriageEnvelope; + try { + parsed = JSON.parse(captured) as TriageEnvelope; + } catch (err) { + throw new Error( + `Failed to parse ${TRIAGE_FENCE_TAG} JSON: ${err instanceof Error ? err.message : String(err)}`, + ); + } + if (!Array.isArray(parsed.issues)) { + throw new Error(`Triage response must contain an issues array`); + } + return parsed.issues.map(normalizeRecommendation); +} + +function normalizeRecommendation(value: unknown): IssueTriageRecommendation { + const row = value as Record; + const issueNumber = Number(row.issueNumber); + if (!Number.isInteger(issueNumber)) throw new Error('Triage issueNumber must be an integer'); + const confidence = clamp01(Number(row.confidence)); + const suggestedAgent = + row.suggestedAgent === 'claude' || + row.suggestedAgent === 'codex' || + row.suggestedAgent === 'openrouter' + ? row.suggestedAgent + : null; + const suggestedLabels = Array.isArray(row.suggestedLabels) + ? row.suggestedLabels + .filter((label): label is string => typeof label === 'string') + .filter((label) => TRIAGE_LABELS.includes(label as (typeof TRIAGE_LABELS)[number])) + : []; + return { + issueNumber, + confidence, + suggestedAgent, + suggestedLabels: Array.from(new Set(suggestedLabels)), + shouldStart: row.shouldStart === true, + needsHuman: row.needsHuman === true, + rationale: typeof row.rationale === 'string' ? row.rationale.slice(0, 500) : '', + }; +} + +function extractFencedJson(text: string, tag: string): string { + const openTag = `\`\`\`${tag}`; + const lines = text.split('\n'); + let collecting = false; + const captured: string[] = []; + for (const line of lines) { + const trimmed = line.trim(); + if (!collecting) { + if (trimmed === openTag || trimmed.startsWith(`${openTag} `)) collecting = true; + continue; + } + if (trimmed === '```') break; + captured.push(line); + } + if (captured.length === 0) throw new Error(`No ${tag} fenced block found in triage response`); + return captured.join('\n').trim(); +} + +function clamp01(value: number): number { + if (!Number.isFinite(value)) return 0; + return Math.min(1, Math.max(0, value)); +} diff --git a/packages/agents/src/index.ts b/packages/agents/src/index.ts index ab82c107..3813403d 100644 --- a/packages/agents/src/index.ts +++ b/packages/agents/src/index.ts @@ -15,6 +15,12 @@ export { export { loadRepoContext, loadStructuredRepoContext } from './context-loader'; export { GhCli } from './github/gh-cli'; export { IssuePoller } from './github/issue-poller'; +export type { IssueTriageRecommendation, IssueTriageRunResult } from './github/issue-triage'; +export { + buildTriagePrompt, + extractTriageRecommendations, + triageGitHubIssues, +} from './github/issue-triage'; export { routeFromLabels } from './github/model-router'; export type { FetchProjectPrioritiesOptions, diff --git a/packages/db/src/queries/settings.ts b/packages/db/src/queries/settings.ts index 740413eb..5b80f5be 100644 --- a/packages/db/src/queries/settings.ts +++ b/packages/db/src/queries/settings.ts @@ -66,6 +66,7 @@ const PROJECT_OPEN_TARGETS = ['cursor', 'finder', 'terminal', 'ghostty', 'vscode const TERMINAL_OPEN_TARGETS = ['terminal', 'ghostty'] as const; const FONT_SIZES = [12, 13, 14, 15] as const; const GENERATOR_CLIS = ['claude', 'codex'] as const; +const EXECUTOR_MODELS = ['claude', 'codex', 'openrouter'] as const; const DEV_LOG_LEVELS = ['error', 'warn', 'info', 'debug'] as const; const UPDATE_TRACKS = ['master', 'stable', 'nightly'] as const; const AUTO_COMMIT_MODES = ['split', 'single'] as const; @@ -112,6 +113,10 @@ function isGeneratorCli(value: unknown): value is AppSettings['prdRewriteCli'] { return typeof value === 'string' && (GENERATOR_CLIS as readonly string[]).includes(value); } +function isExecutorModel(value: unknown): value is AppSettings['triageModel'] { + return typeof value === 'string' && (EXECUTOR_MODELS as readonly string[]).includes(value); +} + export class SettingsQueries { constructor(private db: DatabaseSync) {} @@ -153,6 +158,19 @@ export class SettingsQueries { (stored.reviewerModel as AppSettings['reviewerModel']) ?? DEFAULT_SETTINGS.reviewerModel, executorModel: (stored.executorModel as AppSettings['executorModel']) ?? DEFAULT_SETTINGS.executorModel, + triageModel: isExecutorModel(stored.triageModel) + ? stored.triageModel + : DEFAULT_SETTINGS.triageModel, + triageModelId: readNullable(stored.triageModelId) ?? DEFAULT_SETTINGS.triageModelId, + triageReasoningEffort: isReasoningEffort(stored.triageReasoningEffort) + ? stored.triageReasoningEffort + : DEFAULT_SETTINGS.triageReasoningEffort, + triageAutoApplyThreshold: clampNumber( + stored.triageAutoApplyThreshold, + 0, + 1, + DEFAULT_SETTINGS.triageAutoApplyThreshold, + ), prdRewriteCli: isGeneratorCli(stored.prdRewriteCli) ? stored.prdRewriteCli : DEFAULT_SETTINGS.prdRewriteCli, @@ -293,6 +311,7 @@ export class SettingsQueries { 'reviewerReasoningEffort', 'executorReasoningEffort', 'verifierReasoningEffort', + 'triageReasoningEffort', 'prdRewriteReasoningEffort', ] as const) { if (key in patch && patch[key] != null) { @@ -326,6 +345,17 @@ export class SettingsQueries { throw new Error('prdRewriteCli must be claude|codex'); } } + if ('triageModel' in patch && patch.triageModel != null) { + if (!isExecutorModel(patch.triageModel)) { + throw new Error('triageModel must be claude|codex|openrouter'); + } + } + if ('triageAutoApplyThreshold' in patch && patch.triageAutoApplyThreshold != null) { + const n = Number(patch.triageAutoApplyThreshold); + if (!Number.isFinite(n) || n < 0 || n > 1) { + throw new Error('triageAutoApplyThreshold must be 0–1'); + } + } if ('worktreeBranchFormat' in patch && patch.worktreeBranchFormat != null) { validateBranchFormat(patch.worktreeBranchFormat); } @@ -386,6 +416,11 @@ function clampInt(raw: string | undefined, min: number, max: number, fallback: n return Number.isFinite(n) && n >= min && n <= max ? n : fallback; } +function clampNumber(raw: string | undefined, min: number, max: number, fallback: number): number { + const n = raw ? Number(raw) : Number.NaN; + return Number.isFinite(n) && n >= min && n <= max ? n : fallback; +} + /** Reject branch format strings that would produce invalid git ref names. */ function validateBranchFormat(format: string): void { if (!format.includes('{id}')) { diff --git a/packages/shared/src/constants.ts b/packages/shared/src/constants.ts index 58523479..11fe0d2d 100644 --- a/packages/shared/src/constants.ts +++ b/packages/shared/src/constants.ts @@ -45,6 +45,10 @@ export const DEFAULT_SETTINGS: AppSettings = { reviewerModel: 'codex', verifierModel: 'claude', executorModel: 'claude', + triageModel: 'claude', + triageModelId: PINNED_MODEL_DEFAULTS.claude.triage, + triageReasoningEffort: 'minimal', + triageAutoApplyThreshold: 0.85, prdRewriteCli: 'claude', prdRewriteClaudeModel: PINNED_MODEL_DEFAULTS.claude.prdRewrite, prdRewriteCodexModel: PINNED_MODEL_DEFAULTS.codex.prdRewrite, diff --git a/packages/shared/src/ipc-channels.ts b/packages/shared/src/ipc-channels.ts index cfaf4918..bc7eb6e9 100644 --- a/packages/shared/src/ipc-channels.ts +++ b/packages/shared/src/ipc-channels.ts @@ -23,6 +23,7 @@ import type { GhAuthStatus, GitHubIssueCacheRecord, GitHubIssueComment, + GitHubIssueTriageResult, GitState, GitVisualizerData, HeatmapDayRecord, @@ -258,6 +259,7 @@ export interface IpcInvokeChannels { // GitHub 'github:list-issues': { args: { projectId: string }; result: GitHubIssueCacheRecord[] }; 'github:refresh-issues': { args: { projectId: string }; result: GitHubIssueCacheRecord[] }; + 'github:triage-issues': { args: { projectId: string }; result: GitHubIssueTriageResult }; 'github:start-issue': { args: { projectId: string; issueNumber: number }; result: undefined }; 'github:retry-issue': { args: { projectId: string; issueNumber: number }; result: undefined }; 'github:mark-done': { args: { projectId: string; issueNumber: number }; result: undefined }; diff --git a/packages/shared/src/model-catalog.test.ts b/packages/shared/src/model-catalog.test.ts index 353ad335..b2720df7 100644 --- a/packages/shared/src/model-catalog.test.ts +++ b/packages/shared/src/model-catalog.test.ts @@ -13,10 +13,12 @@ describe('model-catalog', () => { claude: { phase: 'claude-sonnet-4-6', prdRewrite: 'claude-sonnet-4-6', + triage: 'claude-haiku-4-5-20251001', }, codex: { phase: 'gpt-5.4', prdRewrite: 'gpt-5.4-mini', + triage: 'gpt-5.4-mini', }, openrouter: { paid: 'openrouter/auto', diff --git a/packages/shared/src/model-catalog.ts b/packages/shared/src/model-catalog.ts index dca61177..72570ece 100644 --- a/packages/shared/src/model-catalog.ts +++ b/packages/shared/src/model-catalog.ts @@ -68,10 +68,12 @@ export const PINNED_MODEL_DEFAULTS = { claude: { phase: CLAUDE_MODEL_IDS.sonnet46, prdRewrite: CLAUDE_MODEL_IDS.sonnet46, + triage: CLAUDE_MODEL_IDS.haiku45, }, codex: { phase: CODEX_FALLBACK_MODEL_IDS.gpt54, prdRewrite: CODEX_FALLBACK_MODEL_IDS.gpt54Mini, + triage: CODEX_FALLBACK_MODEL_IDS.gpt54Mini, }, openrouter: { paid: OPENROUTER_MODEL_IDS.autoPaid, diff --git a/packages/shared/src/types.ts b/packages/shared/src/types.ts index c9614e7c..75ebf1f1 100644 --- a/packages/shared/src/types.ts +++ b/packages/shared/src/types.ts @@ -604,6 +604,10 @@ export interface AppSettings { reviewerModel: AgentType; verifierModel: AgentType; executorModel: AgentType; + triageModel: ExecutorModel; + triageModelId: string | null; + triageReasoningEffort: ReasoningEffort; + triageAutoApplyThreshold: number; prdRewriteCli: GeneratorCli; prdRewriteClaudeModel: string | null; prdRewriteCodexModel: string | null; @@ -1168,6 +1172,28 @@ export interface GitHubIssueCacheRecord { syncState?: 'creating'; } +export interface GitHubIssueTriageSummary { + issueNumber: number; + confidence: number; + applied: boolean; + suggestedLabels: string[]; + suggestedAgent: ExecutorModel | null; + shouldStart: boolean; + needsHuman: boolean; + rationale: string; +} + +export interface GitHubIssueTriageResult { + provider: ExecutorModel; + modelId: string | null; + resolvedModel: string | null; + consideredCount: number; + appliedCount: number; + skippedCount: number; + threshold: number; + issues: GitHubIssueTriageSummary[]; +} + export const ISSUE_PIPELINE_STATUS = { todo: 'todo', queued: 'queued', diff --git a/packages/ui/src/KanbanBoard.tsx b/packages/ui/src/KanbanBoard.tsx index da92891e..bf81dc12 100644 --- a/packages/ui/src/KanbanBoard.tsx +++ b/packages/ui/src/KanbanBoard.tsx @@ -134,6 +134,8 @@ export function KanbanBoard({ onIssueClick, onCommentIssue, onRefresh, + onTriageIssues, + triagingIssues = false, onStartPipeline, onRetry, onRerun, @@ -575,6 +577,8 @@ export function KanbanBoard({ onStalenessFilterChange={setStalenessFilter} refreshing={refreshing} onRefresh={handleRefresh} + triagingIssues={triagingIssues} + onTriageIssues={onTriageIssues} projectName={projectName} repoUrl={repoUrl} projectsUrl={projectsUrl} diff --git a/packages/ui/src/kanban-board/BoardToolbar.tsx b/packages/ui/src/kanban-board/BoardToolbar.tsx index 31e3d552..90ddd691 100644 --- a/packages/ui/src/kanban-board/BoardToolbar.tsx +++ b/packages/ui/src/kanban-board/BoardToolbar.tsx @@ -1,6 +1,14 @@ 'use client'; -import { CircleAlert, Columns3, ExternalLink, LayoutList, RefreshCw, Workflow } from 'lucide-react'; +import { + BrainCircuit, + CircleAlert, + Columns3, + ExternalLink, + LayoutList, + RefreshCw, + Workflow, +} from 'lucide-react'; import { cn } from '../lib/utils'; import { Button } from '../primitives/button'; import { @@ -33,6 +41,8 @@ interface BoardToolbarProps { onStalenessFilterChange: (filter: 'all' | 'stale') => void; refreshing: boolean; onRefresh: () => void; + triagingIssues?: boolean; + onTriageIssues?: () => void; projectName?: string; repoUrl?: string | null; projectsUrl?: string | null; @@ -57,6 +67,8 @@ export function BoardToolbar({ onStalenessFilterChange, refreshing, onRefresh, + triagingIssues = false, + onTriageIssues, projectName, repoUrl, projectsUrl, @@ -233,6 +245,17 @@ export function BoardToolbar({ + {onTriageIssues && ( + + )} ); diff --git a/packages/ui/src/kanban-board/types.ts b/packages/ui/src/kanban-board/types.ts index ab587132..dee0b74a 100644 --- a/packages/ui/src/kanban-board/types.ts +++ b/packages/ui/src/kanban-board/types.ts @@ -22,6 +22,8 @@ export interface KanbanBoardProps { onIssueClick: (issue: GitHubIssueCacheRecord) => void; onCommentIssue?: (issue: GitHubIssueCacheRecord) => void; onRefresh: () => void; + onTriageIssues?: () => void; + triagingIssues?: boolean; onStartPipeline?: (issue: GitHubIssueCacheRecord) => void; onRetry?: (issue: GitHubIssueCacheRecord) => void; onRerun?: (issue: GitHubIssueCacheRecord) => void;