Data Model: Do Dispatch Open-Op Lifecycle

Entities

OpStartedEvent (replaces started-mode InvocationRecord)

FieldTypeRequiredNotes
eventLiteral["started"]yesdiscriminator
invocation_idstr (ULID, 26)yesidentity; filename stem
profile_idstryesresolved agent profile
actionstryescanonical action token; non-empty
request_textstryesverbatim user request (may be empty only for query mode)
actorstryes"claude" \
mode_of_workstryestask_execution \
governance_context_hashstryes16 hex chars; empty string only when governance_context_available=false
governance_context_availableboolyes
router_confidencestr \Noneno
started_atstr (ISO-8601 UTC)yes
mission_idstr \Noneno
wp_idstr \Noneno

Frozen Pydantic v2 model. Serialized as the first JSONL line; write-once (exclusive create).

OpCompletedEvent (new)

FieldTypeRequiredNotes
eventLiteral["completed"]yesdiscriminator
invocation_idstr (ULID)yesmust match file
completed_atstr (ISO-8601 UTC)yes
outcomeLiteral["done","failed","abandoned"]yesnon-null by construction
closed_byLiteral["agent","doctor_sweep"]yeswho closed: working agent vs stale sweep
evidence_refstr \Noneno

Frozen Pydantic v2 model. Appended once; second append attempt → AlreadyClosedError. Carries no started-only fields — invalid blank-default states are unrepresentable.

Unchanged event shapes (same JSONL file)

  • artifact_link{event, invocation_id, kind, ref, at}
  • commit_link{event, invocation_id, sha, at}
  • glossary_checked — existing bundle shape

Op file (kitty-ops/<invocation_id>.jsonl)

Append-only event log. Line order: started → [glossary_checked] → [completed] → [artifact_link*] → [commit_link]. The file, read alone, answers who/what/when/why/outcome (FR-005).

Ops index (kitty-ops/ops-index.jsonl)

Unchanged: {invocation_id, profile_id, started_at} per started event.

State Machine (Op lifecycle)

            do / ask / advise                    profile-invocation complete
 (none) ────────────────────────► OPEN ─────────────────────────────────────► CLOSED(done|failed)
                                   │                                              ▲
                                   │  doctor ops --close-stale (age > threshold)  │
                                   └──────────────────────────────────────────────┘
                                                CLOSED(abandoned, closed_by=doctor_sweep)

Invariants 1. A started event exists before any other event in the file (exclusive create). 2. At most one completed event per Op; double close raises AlreadyClosedError (idempotent for sweep: reported, not fatal). 3. outcome is never null on a completed event; done is never written by dispatch — only by an explicit close. 4. Open Ops are never auto-committed to git; closed Ops are auto-committed at close time (including sweep closes), commit message op(<profile>): <action> [<id8>]. 5. Evidence promotion is refused for advisory/query modes (existing FR-009 gate preserved). 6. Sweep closes only Ops with started_at older than threshold; --threshold 0 means all open Ops.

Migration mapping (legacy → v2)

Legacy recordDisposition
started event with invocation_id + profile_idrewrite → OpStartedEvent (missing mode_of_work"task_execution"; actor preserved when non-empty)
started event with missing/empty actor or actionemit the literal "unrecorded" for the missing field — never fabricate a plausible value
completed event with non-null outcomerewrite → OpCompletedEvent, closed_by="agent" (missing completed_at → fall back to the started event's started_at, flagged in the migration report)
completed event with null outcome (old auto-close artifacts)rewrite → OpCompletedEvent, outcome="abandoned", closed_by="agent"
link/glossary eventspass through unchanged
file with unparseable/identity-less started eventdelete file
file already v2 (completed has closed_by)skip (idempotency)