/** * capture_work — braindump → a small set of concrete issues. This is the one * place the big model earns its keep (decomposition + estimate negotiation). * It returns a *proposal*; nothing is filed until the human approves it in the * Capture tray and it goes through the create-issue write path. Pure * orchestration over an injected `complete` — stubbable, so it's testable offline. */ import { type EstimateLabel, ESTIMATE_LABELS, type PriorityLabel, PRIORITY_LABELS, } from '../labels/label-schema.js' import type { ChatMessage, CompletionResult, ToolDecl } from './chat-client.js' export interface ProposedIssue { title: string body: string estimate?: EstimateLabel priority?: PriorityLabel } export interface CaptureProposal { issues: ProposedIssue[] /** One-line schedule impact, if the model offered one. */ consequence?: string } export const PROPOSE_ISSUES_TOOL: ToolDecl = { name: 'propose_issues', description: 'Return the decomposed issue set for a braindump. Call this exactly once.', parameters: { type: 'object', properties: { issues: { type: 'array', items: { type: 'object', properties: { title: { type: 'string', description: 'a clear imperative title' }, body: { type: 'string', description: 'one or two lines of detail' }, estimate: { type: 'string', enum: ['est/1d', 'est/2d', 'est/3d', 'est/5d', 'est/8d'] }, priority: { type: 'string', enum: ['p/1', 'p/2', 'p/3', 'p/4'] }, }, required: ['title'], }, }, consequence: { type: 'string', description: 'one-line note on the schedule impact' }, }, required: ['issues'], }, } export const CAPTURE_SYSTEM = [ 'You are Reginald, decomposing a rough braindump into a small set of concrete Gitea issues.', 'Call propose_issues exactly once. Split genuinely separate work; merge trivially-coupled work; invent no scope.', 'Each issue gets an imperative title, a one-line body, an estimate (est/1d…8d) and a priority (p/1…4).', 'Estimate honestly — a "quick" task is rarely one day. Keep the set tight; three good issues beat eight vague ones.', ].join(' ') function isEstimate(v: unknown): v is EstimateLabel { return typeof v === 'string' && (ESTIMATE_LABELS as readonly string[]).includes(v) } function isPriority(v: unknown): v is PriorityLabel { return typeof v === 'string' && (PRIORITY_LABELS as readonly string[]).includes(v) } /** Coerce the model's raw propose_issues args into a validated proposal. */ export function parseCaptureArgs(args: unknown): CaptureProposal { const a = (args ?? {}) as { issues?: unknown[]; consequence?: unknown } const issues: ProposedIssue[] = [] for (const raw of Array.isArray(a.issues) ? a.issues : []) { const r = (raw ?? {}) as Record const title = typeof r.title === 'string' ? r.title.trim() : '' if (!title) continue issues.push({ title, body: typeof r.body === 'string' ? r.body : '', estimate: isEstimate(r.estimate) ? r.estimate : undefined, priority: isPriority(r.priority) ? r.priority : undefined, }) } return { issues, consequence: typeof a.consequence === 'string' ? a.consequence : undefined } } /** * Run one decomposition turn. Forces the model to answer via propose_issues and * returns the validated set. An empty set means the model declined to structure it. */ export async function captureWork( complete: (messages: ChatMessage[], tools?: ToolDecl[]) => Promise, braindump: string, ): Promise { const res = await complete( [ { role: 'system', content: CAPTURE_SYSTEM }, { role: 'user', content: braindump }, ], [PROPOSE_ISSUES_TOOL], ) const call = res.toolCalls.find((t) => t.name === 'propose_issues') if (!call) return { issues: [] } try { return parseCaptureArgs(JSON.parse(call.arguments || '{}')) } catch { return { issues: [] } } }