feat: writes via chat — Reginald proposes, you approve inline (P4)

Completes "chat is the write-path" (decisions.md D1). Ask Reginald to re-estimate
or reprioritize an issue; it formulates a proposal, you approve it inline, and the
write runs through the same guarded apply_changes engine the Issue screen uses.
The model never writes — it proposes; the app owns approval + execution.

core (@commitea/core):
- propose_change tool declaration + REGINALD_SYSTEM updated ("never claim a change
  is applied; you propose, the human approves").
- proposalsFor(args, currentLabels, title): pure — builds the concrete, non-noop
  ChangeProposal(s) (change + label diff) for a propose_change request, dropping
  invalid/unchanged axes. ChangeProposal / ProposeChangeArgs types.

app:
- model bridge executes propose_change by planning against the issue's current
  labels (no write) and returns the proposals with the turn.
- useChat surfaces pending proposals + approve/dismiss; approve calls onApplyChange
  (AppShell's guarded handler → PUT + board/forecast refetch), dismiss leaves it.
- ChatPanel renders each proposal as a propose-approve card (diff + Approve/Dismiss).

Verified: 101 core tests green (4 proposalsFor added), desktop typecheck clean,
14 fixture e2e green. Gated live e2e against gemma-4-26b: "Set the estimate on #3
to est/5d" → Reginald proposes "est/2d → est/5d" as an inline card, says it's
*proposed* not done; Dismiss leaves the repo untouched. The approve→write path is
the #41 engine (separately verified change→revert).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Croissant Le Doux
2026-07-08 21:38:25 -04:00
parent 4fc2e83cd5
commit 47c45ffa3e
10 changed files with 224 additions and 25 deletions

View File

@@ -1,6 +1,6 @@
import { describe, expect, it } from 'vitest'
import { describeChange, type IssueChange, planIssueChange } from './apply-changes-v0.js'
import { describeChange, type IssueChange, planIssueChange, proposalsFor } from './apply-changes-v0.js'
describe('planIssueChange', () => {
it('swaps the estimate label, keeping non-axis labels', () => {
@@ -52,3 +52,28 @@ describe('planIssueChange', () => {
expect(describeChange(planIssueChange(['est/5d'], change))).toBe('no change')
})
})
describe('proposalsFor', () => {
it('builds one proposal per changed axis, carrying the concrete change + diff', () => {
const props = proposalsFor({ issue: 2, estimate: 'est/5d', priority: 'p/1' }, ['est/2d', 'p/3'], 'ChangeSource')
expect(props).toHaveLength(2)
expect(props[0].change).toEqual({ kind: 'reestimate', issue: 2, estimate: 'est/5d' })
expect(describeChange(props[0].plan)).toBe('est/2d → est/5d')
expect(props[1].change).toEqual({ kind: 'reprioritize', issue: 2, priority: 'p/1' })
expect(props[0].issueTitle).toBe('ChangeSource')
})
it('drops a noop axis (already at the requested value)', () => {
const props = proposalsFor({ issue: 2, estimate: 'est/2d', priority: 'p/1' }, ['est/2d', 'p/3'])
expect(props.map((p) => p.change.kind)).toEqual(['reprioritize']) // estimate unchanged
})
it('ignores invalid label values from the model', () => {
const props = proposalsFor({ issue: 2, estimate: 'est/4d' as never, priority: 'high' as never }, [])
expect(props).toEqual([])
})
it('returns nothing when no axis is provided', () => {
expect(proposalsFor({ issue: 2 }, ['est/2d'])).toEqual([])
})
})