The last big agent capability. In the Capture screen, a rough braindump runs real
big-model decomposition into a small, estimated issue set; you review/edit the
labels and approve, and the issues are opened in gitea. This is the one place the
big model earns its keep (docs/agent-tools.md).
core (@commitea/core):
- capture-work: PROPOSE_ISSUES_TOOL + CAPTURE_SYSTEM; captureWork(complete, dump)
forces a single structured decomposition and returns validated issues; parseCaptureArgs
drops blank titles + invalid est/p labels. ProposedIssue / CaptureProposal.
- gitea client: createIssue({title, body?, labelIds?}) → POST /issues, normalized.
app:
- model bridge model:capture runs captureWork on the (loaded) big model.
- gitea bridge gitea:createIssues opens each approved issue with its est/* + p/*
labels (reusing the #41 label-id resolver — zero-pollution, no invented labels).
- Capture screen: when a model is configured, "Brew tickets" runs real capture and
"Approve all" files the set; otherwise the scripted demo interview runs. Fixed a
race — the brew handler re-checks model status at click time so a configured
model never falls into the scripted path before status resolves.
Verified: 108 core tests green (7 capture + createIssue added), desktop typecheck
clean, 14 fixture e2e green. Gated live e2e against gemma-4-26b: the auth braindump
→ 3 real tickets ("Resolve token refresh + session staleness" est/3d p/1, "Fix
webhook double-firing" est/2d p/2, "Write auth setup docs" est/1d p/3), reviewable
and editable; Discard so the test files nothing (createIssue POST is unit-tested).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
110 lines
3.9 KiB
TypeScript
110 lines
3.9 KiB
TypeScript
/**
|
|
* 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<string, unknown>
|
|
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<CompletionResult>,
|
|
braindump: string,
|
|
): Promise<CaptureProposal> {
|
|
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: [] }
|
|
}
|
|
}
|