feat: capture_work — braindump → decomposed issues → filed in gitea (P4)

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>
This commit is contained in:
Croissant Le Doux
2026-07-08 22:08:37 -04:00
parent 652c8c86b9
commit 6e8a6a15bc
11 changed files with 402 additions and 22 deletions

View File

@@ -1,8 +1,12 @@
import React from 'react'
import type { ProposedIssue } from '@commitea/core'
import { Badge, Button, Card, Icon, Select, Tag } from '../ui/index.js'
// Capture interview — braindump → interview → approved ticket set (< 2 min)
// Capture interview — braindump → interview → approved ticket set (< 2 min).
// With a model configured, "Brew tickets" runs real capture_work decomposition
// and "Approve all" files the issues in gitea; otherwise the scripted demo runs.
interface Ticket {
title: string
@@ -30,6 +34,64 @@ export function CaptureScreen({ onDone }: { onDone: () => void }) {
const [webEst, setWebEst] = React.useState<string | null>(null)
const [secs, setSecs] = React.useState(0)
// live capture_work path (real model + gitea writes)
const [live, setLive] = React.useState(false)
const [brewing, setBrewing] = React.useState(false)
const [liveTickets, setLiveTickets] = React.useState<ProposedIssue[] | null>(null)
const [filedCount, setFiledCount] = React.useState(0)
const [error, setError] = React.useState<string | null>(null)
React.useEffect(() => {
let alive = true
window.commitea.model.status().then((s) => { if (alive) setLive(s.configured) }).catch(() => {})
return () => { alive = false }
}, [])
const editTicket = (i: number, field: 'estimate' | 'priority', value: string) =>
setLiveTickets((ts) => (ts ? ts.map((t, j) => (j === i ? { ...t, [field]: value } : t)) : ts))
// Decide live-vs-scripted at click time: re-check status if it hasn't resolved
// yet, so a configured model never falls into the scripted interview by a race.
const onBrew = async () => {
const configured = live || (await window.commitea.model.status().then((s) => s.configured).catch(() => false))
if (configured) {
setLive(true)
brew()
} else {
setStage('interview')
}
}
const brew = () => {
setError(null)
setBrewing(true)
window.commitea.model
.capture(dump)
.then((res) => {
setBrewing(false)
if (res.ok && res.issues.length) {
setLiveTickets(res.issues)
setStage('review')
} else {
setError(res.ok ? 'I could not find concrete work in that — try a bit more detail.' : `Capture failed: ${res.reason === 'error' ? res.message : 'no model'}`)
}
})
.catch(() => { setBrewing(false); setError('I could not reach the model.') })
}
const fileLive = () => {
if (!liveTickets) return
setBrewing(true)
window.commitea.gitea
.createIssues(liveTickets)
.then((res) => {
setBrewing(false)
if (res.ok) { setFiledCount(res.created.length); setStage('filed') }
else setError('Filing failed — gitea is not configured.')
})
.catch(() => { setBrewing(false); setError('Filing failed.') })
}
const running = stage === 'interview' || stage === 'review'
React.useEffect(() => {
if (!running) return
@@ -63,19 +125,23 @@ export function CaptureScreen({ onDone }: { onDone: () => void }) {
else setStage('review')
}
// draft tickets build as the interview progresses
const tickets: Ticket[] = []
// draft tickets build as the interview progresses (scripted demo path)
const scripted: Ticket[] = []
if (split === true) {
tickets.push({ title: 'Token refresh: retry with backoff', est: 'est/2d', p: 'p/2' })
tickets.push({ title: 'Session storage: stale reads on wake', est: 'est/1d', p: 'p/3' })
scripted.push({ title: 'Token refresh: retry with backoff', est: 'est/2d', p: 'p/2' })
scripted.push({ title: 'Session storage: stale reads on wake', est: 'est/1d', p: 'p/3' })
} else if (split === false) {
tickets.push({ title: 'Auth: token refresh + session storage', est: 'est/3d', p: 'p/2' })
scripted.push({ title: 'Auth: token refresh + session storage', est: 'est/3d', p: 'p/2' })
}
if (webEst) tickets.push({ title: 'Webhook debounce: double-fire guard', est: webEst, p: 'p/1', dep: 'blocked by auth work' })
if (stage === 'review' || stage === 'filed') {
tickets.push({ title: 'Docs: auth setup guide', est: 'est/1d', p: 'p/4', byReginald: true })
if (webEst) scripted.push({ title: 'Webhook debounce: double-fire guard', est: webEst, p: 'p/1', dep: 'blocked by auth work' })
if (!live && (stage === 'review' || stage === 'filed')) {
scripted.push({ title: 'Docs: auth setup guide', est: 'est/1d', p: 'p/4', byReginald: true })
}
const totalDays = tickets.reduce((n, t) => n + parseInt(t.est.replace('est/', '')), 0)
// real capture_work output overrides the scripted set when present
const tickets: Ticket[] = liveTickets
? liveTickets.map((i) => ({ title: i.title, est: i.estimate ?? 'est/2d', p: i.priority ?? 'p/3' }))
: scripted
const totalDays = tickets.reduce((n, t) => n + parseInt(t.est.replace('est/', ''), 10), 0)
const estOptions = ['est/1d', 'est/2d', 'est/3d', 'est/5d', 'est/8d'].map((v) => ({ value: v, label: v }))
const pOptions = ['p/1', 'p/2', 'p/3', 'p/4'].map((v) => ({ value: v, label: v }))
@@ -96,8 +162,20 @@ export function CaptureScreen({ onDone }: { onDone: () => void }) {
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
{editable ? (
<>
<Select options={estOptions} defaultValue={t.est} style={{ width: 96 }} />
<Select options={pOptions} defaultValue={t.p} style={{ width: 76 }} />
<Select
options={estOptions}
value={liveTickets ? t.est : undefined}
defaultValue={liveTickets ? undefined : t.est}
onChange={(e) => editTicket(i, 'estimate', e.target.value)}
style={{ width: 96 }}
/>
<Select
options={pOptions}
value={liveTickets ? t.p : undefined}
defaultValue={liveTickets ? undefined : t.p}
onChange={(e) => editTicket(i, 'priority', e.target.value)}
style={{ width: 76 }}
/>
</>
) : (
<>
@@ -144,7 +222,12 @@ export function CaptureScreen({ onDone }: { onDone: () => void }) {
<p style={{ font: 'var(--text-agent)', color: 'var(--ink-2)', margin: '10px 0 14px' }}>
Sentences, fragments, grievances all welcome. I'll sort it into tickets and only ask what I can't infer.
</p>
<Button icon="sparkles" onClick={() => setStage('interview')}>Brew tickets</Button>
<Button icon="sparkles" onClick={() => void onBrew()} disabled={brewing}>
{brewing ? 'Brewing…' : 'Brew tickets'}
</Button>
{error ? (
<p style={{ font: 'var(--text-agent)', color: 'var(--danger)', margin: '10px 0 0' }}>{error}</p>
) : null}
</Card>
) : null}
@@ -174,15 +257,26 @@ export function CaptureScreen({ onDone }: { onDone: () => void }) {
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1.1fr', gap: 14, alignItems: 'start' }}>
<Card overline="Consequence" title={`${tickets.length} tickets · ~${totalDays}d of work`} jade
footer={<>
<Button onClick={() => setStage('filed')}>Approve all</Button>
<Button variant="ghost" onClick={() => { setStage('dump'); setQi(0); setLog([]); setSplit(null); setWebEst(null); setSecs(0); }}>Discard</Button>
<Button onClick={() => (live ? fileLive() : setStage('filed'))} disabled={brewing}>
{brewing ? 'Filing…' : 'Approve all'}
</Button>
<Button variant="ghost" onClick={() => { setStage('dump'); setQi(0); setLog([]); setSplit(null); setWebEst(null); setSecs(0); setLiveTickets(null); setError(null); }}>Discard</Button>
</>}>
<p style={{ font: 'var(--text-body)', margin: '0 0 8px' }}>
Beta's 80% window moves <span style={{ font: 'var(--text-data)' }}>Mar 312 → Mar 514</span>. Capacity absorbs the rest.
</p>
<p style={{ font: 'var(--text-agent)', color: 'var(--ink-2)', margin: 0 }}>
I added the docs ticket you mentioned and wired the dependency. Shall I make it so?
</p>
{live ? (
<p style={{ font: 'var(--text-agent)', color: 'var(--ink-2)', margin: 0 }}>
{tickets.length} issue{tickets.length === 1 ? '' : 's'} from your braindump, estimated and prioritized.
Adjust the labels, then approve I'll open them in gitea with only est/* and p/* labels.
</p>
) : (
<>
<p style={{ font: 'var(--text-body)', margin: '0 0 8px' }}>
Beta's 80% window moves <span style={{ font: 'var(--text-data)' }}>Mar 312 Mar 514</span>. Capacity absorbs the rest.
</p>
<p style={{ font: 'var(--text-agent)', color: 'var(--ink-2)', margin: 0 }}>
I added the docs ticket you mentioned and wired the dependency. Shall I make it so?
</p>
</>
)}
</Card>
<Tray editable />
</div>
@@ -195,7 +289,7 @@ export function CaptureScreen({ onDone }: { onDone: () => void }) {
<Icon name="circle-check" size={22} /> Filed
</span>
<p style={{ font: 'var(--text-body)', margin: 0 }}>
{tickets.length} issues opened in gitea with <span style={{ font: 'var(--text-data)' }}>est/*</span> and <span style={{ font: 'var(--text-data)' }}>p/*</span> labels — nothing else touched.
{live ? filedCount : tickets.length} issues opened in gitea with <span style={{ font: 'var(--text-data)' }}>est/*</span> and <span style={{ font: 'var(--text-data)' }}>p/*</span> labels nothing else touched.
</p>
<p style={{ font: 'var(--text-agent)', color: 'var(--ink-2)', margin: 0 }}>
Elapsed {clock} under budget. No bot comments, no synthetic issues; your repo remains yours.

View File

@@ -2,12 +2,14 @@ import type {
AgentStep,
ChangeProposal,
ChatMessage,
CaptureProposal,
DependencyEdge,
GiteaIssue,
GiteaMilestone,
IssueChange,
LabelPlan,
LifecycleEvent,
ProposedIssue,
} from '@commitea/core'
/** The result of a write through the bridge. */
@@ -15,6 +17,16 @@ export type ApplyChangeResult =
| { ok: false; reason: 'unconfigured' }
| { ok: true; plan: LabelPlan; issue: GiteaIssue }
/** The result of filing captured issues. */
export type CreateIssuesResult =
| { ok: false; reason: 'unconfigured' }
| { ok: true; created: { number: number; title: string }[] }
/** The result of a capture_work decomposition. */
export type CaptureResult =
| { ok: false; reason: 'unconfigured' | 'error'; message?: string }
| ({ ok: true } & CaptureProposal)
/** The gitea bridge exposed by the preload over IPC (main-process backed). */
export interface GiteaBridge {
status(): Promise<{ configured: boolean; repo: string | null }>
@@ -28,6 +40,7 @@ export interface GiteaBridge {
}>
getIssue(index: number): Promise<GiteaIssue | null>
applyChange(change: IssueChange): Promise<ApplyChangeResult>
createIssues(issues: ProposedIssue[]): Promise<CreateIssuesResult>
}
/** One agent turn's result. */
@@ -39,6 +52,7 @@ export type ChatResult =
export interface ModelBridge {
status(): Promise<{ configured: boolean; model: string | null }>
chat(messages: ChatMessage[]): Promise<ChatResult>
capture(braindump: string): Promise<CaptureResult>
}
declare global {