30 tasks · 4 phases · 4 files · 2026-09-06

Give handoffs a disposition so the pane can drain

A handoff records only whether it was sent, so sending is the only way one stops counting — fourteen sit unsent across three reports and two of them read “5 of 5 still to send”. Add held / sent / done / dropped, borrowing the finding lifecycle’s vocabulary.

30Tasks
17Implementation
4Files touched
0Migrations
The gist

MiraHandoff carries one fact about your relationship to a piece of work: sentAt. Add a disposition beside it — held, sent, done, dropped — and switch every counting site from “not sent” to “still open”. The vocabulary is lifted from TriageLifecycleState, which already solved this for findings.

The problem

MiraHandoff has sentAt: Date? and the derived isSent, and that is the entire state machine. Five call sites read it: handoffSubtitle (MiraLedPaneView:325), openReports (:80), finishedReports (:86), openHandoffCount (MiraHandoffStore:73), and handoffContextSummary (:91).

Sending is therefore the only transition available. Fourteen unsent handoffs sit across three reports; a handoff read in August and decided against is indistinguishable from one never opened. Two reports read “5 of 5 still to send” and will keep reading that indefinitely, which makes the count worth ignoring — and a count worth ignoring is worse than no count.

Approach

disposition counts, sentAt timestamps

Both fields stay. isSent keeps its current meaning (sentAt != nil) and keeps gating the editing affordances — checkbox toggles, the notes editor, the “Send again” label. Every site that reads isSent to count switches to disposition; nothing that reads it for editability changes.

The two can disagree, and that is the intent. A handoff sent in July and abandoned in August is .dropped with a non-nil sentAt. The timestamp is history; the disposition is status.

Four cases, one of them open

held is the default and the only state that counts. sent, done and dropped all drain. dropped is the overridden analogue from TriageLifecycleState: never counted, never resurfaced, and not listed back to Mira in her context summary.

Migration is one line in the existing decoder

MiraHandoff already hand-writes init(from:) with decodeIfPresent on every field. The new one defaults to sentAt != nil ? .sent : .held, so the fourteen stored handoffs land in the right state with no migration pass and no version stamp.

Two counting sites the note did not list

openReports and finishedReports (MiraLedPaneView lines 78–86) also partition on isSent. Left alone, a report whose handoffs are every one of them dropped would sit in the open list forever with the subtitle reading “nothing open”. Phase 3 changes both.

Decisions made for you

Judgment call

“Send again” will not un-finish a done handoff

markSent promotes to .sent only from .held. Setting it unconditionally would mean re-opening a completed prompt to copy it quietly reverted the state you had recorded. Reversible by dropping the guard.

Judgment call

Dropping is confirmed rather than reversible

The note asks for dropped to behave like overridden: gone, permanent. Strict permanence and a mis-click are a bad combination, so the menu offers no revert and a confirmationDialog stands in for one. Deleting the report remains the escape hatch.

Judgment call

Marking done retires findings; dropping does not

Sending already retires the source findings as .accepted. Marking a never-sent handoff done should do the same, or work you finished keeps feeding the triage queue. Dropping deliberately does not: coveredFindingKeys already includes a dropped handoff’s keys, so regeneration will not re-cluster them, and the findings stay individually visible in the raw-findings section. Rejecting Mira’s framing of the work is not the same as silencing the observations behind it.

Judgment call

Dropped handoffs are omitted from Mira’s context, not counted

Even a bare count invites her to raise what you silenced. Done gets a count line; dropped gets nothing.

The work

Phase 1 — The model

5 tasks Lands alone. Nothing reads the new field yet.
  • Add MiraHandoffDisposition enum to Code Raven/Models/MiraHandoff.swift above MiraHandoff, with cases held, sent, done, dropped, conforming to String, Codable, CaseIterable
  • Give the enum label, accent, systemImage, countsAsOpen (true only for held), and isTerminal (true for done and dropped), matching the shape of MiraHandoffConfidence in the same file
  • Add var disposition: MiraHandoffDisposition to MiraHandoff beside sentAt, with = .held in the memberwise init so MiraHandoffGenerator.parse needs no change
  • Add disposition to MiraHandoff.CodingKeys and decode it in the hand-written init(from:), defaulting to sentAt != nil ? .sent : .held so the fourteen stored handoffs load with the right state
  • Add var isOpen: Bool { disposition.countsAsOpen } to MiraHandoff so no call site re-derives the rule

Phase 2 — The store

4 tasks Depends on Phase 1.
  • Add setDisposition(_:handoffId:reportId:projectId:) to MiraHandoffStore immediately after markSent, routing through the private mutateHandoff
  • Have markSent set disposition = .sent only when the current disposition is .held, so “Send again” on a done or dropped handoff does not silently un-finish it
  • Change openHandoffCount to .filter { $0.isOpen }
  • Rewrite handoffContextSummary to bucket by disposition: held listed by title, sent listed by title, done as a bare count line, dropped omitted entirely

Phase 3 — The counting surfaces

2 tasks This is the part that makes the pane drain.
  • Change MiraLedPaneView.handoffSubtitle to count $0.isOpen, reading “N of M open” and “M handoffs · nothing open”
  • Change openReports and finishedReports to partition on isOpen, so a report whose handoffs are all dropped moves into the finished list

Phase 4 — Surfacing it

6 tasks Depends on Phase 3. A quiet mark, not a badge cluster.
  • Add a dispositionMark(_:) view to MiraHandoffReportView — icon plus label in the disposition’s accent, no capsule — placed in cardHeader left of the confidence badge, rendered only when the disposition is not held
  • Change the card’s trailing .opacity to read from disposition: 1.0 held, 0.62 sent and done, 0.45 dropped
  • Add a Menu to sendRow beside the Send button offering “Mark as done”, “Drop this”, and “Back to open”, omitting entries the current disposition makes meaningless
  • Gate “Drop this” behind a confirmationDialog stating that dropping is permanent, since the state has no reverse
  • Call retireSourceFindings(of:) when a held handoff is marked done directly, and skip it when the handoff was already sent
  • Leave source findings untouched when a handoff is dropped, with a comment recording why

Verification

3 tasks Claude’s, before handing back.
  • Build with xcodebuild -scheme "Code Raven" -destination 'platform=macOS' build
  • Confirm the build product is fresh with scripts/verify-build-product.sh
  • Grep for any remaining !$0.isSent in Code Raven/Views/Mira/ to confirm no counting site was missed

Manual testing

7 tasks Yours — the pane cannot be driven from a terminal here.
  • Open the Mira-led pane and confirm the three existing reports list with open counts rather than “5 of 5 still to send”you
  • Mark one held handoff done and confirm the subtitle decrements immediatelyyou
  • Drop one held handoff, confirm the dialog appears, and confirm the card fades and the count decrementsyou
  • Quit and relaunch, then confirm both dispositions survived the round-trip to diskyou
  • Drain every handoff in one report and confirm it moves from the open list into the finished listyou
  • Ask Mira what you are working on and confirm she never names the dropped handoffyou
  • Press “Send again” on a handoff already marked done and confirm it stays marked doneyou

Risks & edge cases

Edge case

Two fields, one concept

sentAt and disposition both describe sending. The plan assigns each a job and keeps them consistent through markSent, but a future call site that sets sentAt directly would desynchronise them with no compiler complaint. Collapsing sentAt into an associated value on .sent would rule that out, at the cost of rewriting the decoder and every isSent read.

Thin spot

A held pile still has no time dimension

Fourteen handoffs held today become fourteen held in November. Nothing in this version distinguishes “decided against, never marked” from “not read yet”. Fading is the answer and it is deliberately deferred; the signal to build it is the held list becoming unreadable again.

Dead code

openHandoffCount has no callers

Its doc comment says it drives the pane badge. A grep across the project finds no reference. Phase 2 fixes it anyway because leaving one counting function on the old rule is how the next surface picks up the bug — worth knowing that fixing it changes nothing visible.

Unverified

Runtime behaviour cannot be checked here

GUI verification is not available from the terminal in this project. Every claim in the verification phase is a compile check; the manual testing list is the only thing that confirms the pane actually drains.

Out of scope

No decay on held

Findings have fading and fadeThreshold, and a handoff held since August is arguably the same state. Add it only if the pile stops being readable without it.

No un-drop

dropped matches overridden. The confirmation dialog is what protects against a mis-click; deleting the report is the only reversal.

No per-disposition filter or sort in the report body

Dropped cards stay in place, faded. The report is the record of what Mira proposed, and hiding part of it makes the record lie.

Final review

The work is small and the shape is borrowed rather than invented, which is where most of the confidence comes from. TriageLifecycleState already proved this vocabulary in the same subsystem, the decoder already has the defaulting pattern the migration needs, and mutateHandoff already does the persistence. Seventeen implementation tasks across four files, and no data migration.

The weakest part is keeping sentAt alongside disposition. It is the right call for this change — folding the timestamp into the enum would touch every isSent read for no user-visible gain — but it leaves two fields describing one concept, held in sync by convention rather than by the type system. If a third thing ever writes sentAt, the counts drift silently.

The adjustment worth considering is whether sent should count as open. It currently does not, and this plan preserves that. A handoff sent to Claude Code and never followed up on is arguably still your problem, and it would show up in a “3 open” count if it did. Leaving it out keeps the change to the stated scope; if the sent pile turns out to hide unfinished work, the fix is one line in countsAsOpen.

86/100

High on the mechanism: the enum, the decoder default, and the store method are all copies of patterns already working in this subsystem, and the two extra counting sites the note missed are found and included. Held back from higher by the sentAt / disposition redundancy, which is correct for this change but leaves a consistency invariant enforced by convention, and by the UI half being unverifiable from here.