Files
WhispAssist/docs/04-api-contracts.md
T

355 lines
25 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 04 — API Contracts
Two contract surfaces:
1. **Frontend ⇄ Rust** — Tauri **commands** (request/response) and **events** (Rust→UI push).
2. **Rust internal** — service **traits** that decouple callers from concrete engines (NFR-MNT-1/2).
All payloads are `serde`-serializable; timestamps are unix epoch ms unless noted. Errors are typed:
each command returns `Result<T, WaError>` where `WaError` carries a `kind` (machine-readable) and a
`message` (human-readable).
## 1. Tauri commands (frontend → Rust)
```ts
// ---- Recording lifecycle ----
// `record` (default false) controls audio RETENTION (ADR-0009). When false, working audio is
// deleted on finalize and only the transcript/notes persist. It can be toggled mid-meeting.
// templateId (Phase 8, T8.1, FR-NOTE-5) picks a NoteTemplate — see list_note_templates below.
// language (T8.7, FR-TRX-4, M4.2): omitted/"auto" requests auto-detection; an ISO-639-1 code
// (e.g. "es") forces that language. Falls back to Settings.whisper_language when omitted.
// Only takes effect with a multilingual model loaded (ModelInfo.multilingual) — an English-only
// model forces "en" regardless (see resolve_language in src-tauri/src/transcription/mod.rs).
start_recording(input: { meetingTitle?: string; calendarEventId?: string; record?: boolean; templateId?: string; language?: string }): MeetingId
stop_recording(input: { meetingId: MeetingId }): MeetingSummaryRef
pause_recording(input: { meetingId: MeetingId }): void
resume_recording(input: { meetingId: MeetingId }): void
// Mute/unmute the mic mid-meeting (FR-CAP-7): mic channel goes silent (recording + transcript + meter),
// loopback keeps capturing. Returns the new muted state; emits recording://mic. Errs if the mic is off.
toggle_microphone_mute(input: { meetingId: MeetingId }): boolean
set_recording_retention(input: { meetingId: MeetingId; record: boolean }): void // toggle mid-meeting (FR-REC-1)
acknowledge_recording_consent(): void // one-time (FR-REC-2)
// ---- Live notes (Granola-style redesign, `03-data-model.md`'s manual_notes.json) ----
// Both are live-session only (err "no matching active recording" once finalized — post-finalize,
// notes.md is the single editable document and `update_notes` is the command for it).
update_live_notes(input: { meetingId: MeetingId; markdown: string }): void // freeform notes typed while recording
// anchorMs: the clicked transcript segment's start_ms, not its id (survives re-transcription).
// text: "" clears that moment's note. Merged into notes.md right after the transcript paragraph
// covering anchorMs when the meeting finalizes (notes::MarkdownNotes::merge).
set_segment_note(input: { meetingId: MeetingId; anchorMs: number; text: string }): void
// ---- Hardware ----
hardware_status(): { backends: BackendInfo[]; active: BackendId; modelSize: string; estRtf: number } // re-detects fresh each call — backs the Settings ▸ Hardware "Refresh" button
set_preferred_backend(input: { backend: BackendId | "auto" }): void
// Launch-at-login (NFR-RES-4). Writes/removes a per-user OS Run entry (no admin) and persists auto_start. Opt-in, off by default.
set_auto_start(input: { enabled: boolean }): void
// Device test: opens a mic ("input") or the render device in loopback ("loopback") for a few seconds and streams device://level (no recording, no retained audio). Refused while recording.
monitor_audio_level(input: { kind: "input" | "loopback"; deviceId?: string; durationMs?: number }): void
// Quick stress test: benchmarks each available backend × installed model (≤3 sizes) on a fixed sample, returns per-pair real-time factor + the most-accurate real-time-capable recommendation. Emits stress://progress. Refused while recording.
stress_test_hardware(): { results: { backend: string; model: string; rtf: number; realtime: boolean }[]; recommended: { backend: string; model: string } | null }
// ---- Transcription / models ----
// language (T8.7, M4.2): omitted reuses the meeting's current language rather than resetting it.
reprocess_transcript(input: { meetingId: MeetingId; model: string; language?: string }): void // batch mode (FR-TRX-3)
// ModelInfo gained `multilingual: boolean` (T8.7, FR-TRX-4, M4.2) — false for `.en` (English-only)
// ggml variants, true for the multilingual ones; gates the Settings language picker.
list_models(): ModelInfo[]
list_diarization_models(): ModelInfo[] // fixed seg+emb pair (T4.7, FR-MODEL-1)
download_model(input: { kind: "whisper" | "diar-seg" | "diar-emb"; id: string }): void // emits progress events
remove_model(input: { id: string }): void // disambiguated by id, not kind — ids never collide across catalogs
// Static catalog of whisper.cpp-recognized ISO-639-1 codes for the Settings language dropdown
// (T8.7, FR-TRX-4, M4.2); "Auto-detect" is a frontend-only addition, not in this list.
list_whisper_languages(): { code: string; label: string }[]
// ---- Speakers ----
rename_speaker(input: { meetingId: MeetingId; label: string; name: string }): void
merge_speakers(input: { meetingId: MeetingId; from: string[]; into: string }): void
map_speaker_to_participant(input: { meetingId: MeetingId; label: string; participantId: string }): void
// ---- Meetings / storage ----
// MeetingListItem gained a `tags: string[]` field (Phase 8, FR-SEARCH-2).
// list_meetings dropped limit/offset (never implemented — no pagination need
// yet at local-desktop meeting counts) and gained from/to date filters, per
// FR-SEARCH-2's "filter by date, tag, or participant".
list_meetings(input: { query?: string; tag?: string; participantId?: string; from?: number; to?: number }): MeetingListItem[]
// Meeting also carries `action_items: ActionItem[]` — table-backed (the confirmed/edited source
// of truth), falling back to summary.json drafts until any are saved (FR-LLM-3). TranscriptSegment
// keeps start_ms/end_ms; the UI renders these as per-line timestamps.
get_meeting(input: { meetingId: MeetingId }): Meeting // includes transcript + speakers + summary (null until generated)
delete_meeting(input: { meetingId: MeetingId }): void
// format "bundle" writes a portable folder: audio.wav + transcript.json + notes.md + summary.json
// (all decrypted) + meeting.json (the MeetingBundle manifest), re-importable on another machine.
// format "obsidian" writes one self-contained vault note (dest is a .md file path, no audio):
// YAML frontmatter (title/date/duration/participants/tags/source) + notes + summary + decisions
// + action items + timestamped transcript. For dropping a meeting into an Obsidian vault.
export_meeting(input: { meetingId: MeetingId; dest: string; format: "md" | "pdf" | "docx" | "bundle" | "obsidian" }): string
// Edit commands that change an uploaded artifact — update_notes, set_tags, generate_summary,
// reprocess_transcript, confirm_action_items — auto-resync when sync is enabled (FR-SYNC-5):
// they enqueue for finalize-trigger targets and pump in the background. SHA-256 dedup means an
// edit that didn't alter a file uploads nothing.
update_notes(input: { meetingId: MeetingId; markdown: string }): void
// AI-enhance rough notes into structured Markdown grounded in the transcript (Granola-style), via the configured LlmProvider (no new egress). Takes the live buffer, returns the enhanced text WITHOUT persisting — the UI keeps or undoes it. Refused while recording; errors with no provider.
enhance_notes(input: { meetingId: MeetingId; notes: string }): string
// SearchHit = MeetingListItem fields (id, title, started_at, duration_secs, status, tags) + snippet: string
search(input: { query: string }): SearchHit[] // FTS (FR-SEARCH-1)
set_tags(input: { meetingId: MeetingId; tags: string[] }): void
list_tags(): string[] // all known tag names, sorted
// NoteTemplate = { id: string, name: string, sections: string[] } (T8.1, FR-NOTE-5). Meeting
// gained `template_id: string | null` — the template picked at start_recording.
list_note_templates(): NoteTemplate[]
// Bulk export (T8.5, FR-STORE-4): every meeting matching tag/from/to, one file (or bundle
// folder) per meeting under destDir. Returns the count actually exported.
bulk_export_meetings(input: { destDir: string; format: "md" | "pdf" | "docx" | "bundle"; tag?: string; from?: number; to?: number }): number
// Import bundle(s) (FR-STORE-4): `dir` is a single bundle folder (has meeting.json) or a parent
// folder of them (from a bulk export). Each is reconstructed under a fresh meeting id (original
// title/date/duration/speakers/tags/action items preserved). Returns the count imported.
import_meeting_bundle(input: { dir: string }): number
// Add a meeting from an existing recording: a local audio/video file path or a URL (YouTube/
// streaming page or direct media URL). Needs ffmpeg (+ yt-dlp for URLs) on PATH; neither bundled.
// `model` overrides the Settings whisper model for this import (recorded as meeting.model_used).
// Returns the new meeting id IMMEDIATELY (status "transcribing"); transcode→transcribe→diarize→
// finalize run in the background, streaming import://progress and ending with transcript://finalized.
// A failed import is left in the list with status "error" (not deleted).
import_media(input: { source: string; title?: string; model?: string }): MeetingId
// ---- LLM / AI provider (ADR-0007/0011) ----
// provider ∈ ollama | custom | anthropic | openai | off. Hosted-provider API keys are passed to
// set_llm_provider but stored only in the OS credential store; never returned by llm_status.
llm_status(): { provider: string; reachable: boolean; isLocal: boolean; models: string[] }
set_llm_provider(input: { provider: string; endpoint?: string; model?: string; apiKey?: string }): void // FR-AI-1/2
generate_summary(input: { meetingId: MeetingId; templateId?: string }): void // streams via events (FR-LLM-2/4)
// ActionItem gained `reminder_set: boolean` (T8.6, FR-CAL-5). confirm_action_items now also
// schedules/cancels each item's local reminder (Windows scheduled toast notification — the OS
// itself delivers it at due_at, no app-side polling timer; see src-tauri/src/reminders.rs).
confirm_action_items(input: { meetingId: MeetingId; items: ActionItem[] }): void
llm_setup_suggestions(): { ollamaInstalled: boolean; installUrl: string; suggestedModel: { id: string; label: string; approxSizeGb: number } } // T5.7, FR-LLM-5
pull_ollama_model(input: { model: string }): void // guided download via Ollama's own /api/pull; emits model://progress (T5.7)
// ---- Calendar / .pst ----
// rangeDays: only import events starting within the last N days; omitted imports the full mailbox
// history. Bug fix: a long-lived .pst has no natural upper bound on history (every recurring
// series expands to its cap, T4.1's RECURRENCE_MAX_OCCURRENCES, plus every one-off entry the file
// ever held, e.g. a decade of Outlook's auto-generated yearly holidays) -- unbounded import could
// produce tens of thousands of rows. Settings.pst_import_range_days persists the last choice and
// applies it to pst_auto_sync's startup re-import too.
import_pst(input: { path: string; password?: string; rangeDays?: number }): number // eventsImported; emits pst://progress (FR-CAL-1)
list_calendar_events(input: { from?: number; to?: number }): CalendarEvent[]
get_calendar_event(input: { eventId: string }): { event: CalendarEvent; participants: Participant[] } // pre-meeting panel + naming dropdown (FR-CAL-3, FR-SPK-4)
// olderThanDays omitted deletes every unlinked event ("Delete all"); Some(n) only those starting
// more than n days ago. An event attached to a recorded meeting (meetings.calendar_event_id) is
// always kept regardless of the choice -- protected reports how many were skipped for that reason.
cleanup_calendar_events(input: { olderThanDays?: number }): { deleted: number; protected: number }
attach_meeting_to_event(input: { meetingId: MeetingId; eventId: string }): void
// Optional MS Graph calendar source (M4.4, T8.9, FR-CAL-6): opt-in, explicit consent (OAuth PKCE
// + Microsoft's own consent screen), metadata-only (subject/organizer/start/end/attendees, never
// the event body). Not a SyncTarget — begin_graph_calendar_link stores its token separately from
// sync_targets and never appears in list_sync_targets.
begin_graph_calendar_link(): { authUrl: string } // opens in browser; emits calendar://linked when done
import_graph_calendar(input: { from?: number; to?: number }): number // eventsImported; emits calendar://progress
disconnect_graph_calendar(): void // best-effort credential cleanup + settings reset
// ---- Sync / upload (ADR-0010) ---- secrets are passed to add/update but stored only in the OS
// credential store; they are NEVER returned by list_sync_targets.
list_sync_targets(): SyncTargetInfo[]
add_sync_target(input: SyncTargetConfig & { secret: string }): SyncTargetInfo // FR-SYNC-2/9
update_sync_target(input: { id: string } & Partial<SyncTargetConfig> & { secret?: string }): SyncTargetInfo
remove_sync_target(input: { id: string }): void
test_sync_target(input: { id: string } | SyncTargetConfig & { secret: string }): { ok: boolean; message: string } // FR-SYNC-4
set_sync_enabled(input: { enabled: boolean }): void // master switch (FR-SYNC-1)
sync_meeting(input: { meetingId: MeetingId; targetId?: string }): void // manual "Upload now" (FR-SYNC-3)
sync_status(input?: { meetingId?: MeetingId }): SyncJobInfo[] // queue state (FR-SYNC-5)
retry_sync_job(input: { jobId: string }): void
// OAuth (secondary targets, FR-SYNC-9): begins loopback-redirect PKCE flow, returns when linked.
begin_oauth_link(input: { kind: "onedrive" | "dropbox" | "box" }): { ok: boolean; account?: string }
// ---- Feature briefs + MCP server (ADR-0011) ----
create_feature_brief(input: { meetingId: MeetingId; targetRepo?: string }): FeatureBrief // FR-MCP-4 (LLM-distilled)
list_feature_briefs(input: { meetingId?: MeetingId }): FeatureBriefInfo[]
get_feature_brief(input: { id: string }): FeatureBrief
set_brief_exposed(input: { id: string; exposed: boolean }): void // scope control (FR-MCP-3)
mcp_status(): { enabled: boolean; transport: "http" | "stdio"; endpoint: string; tokenSet: boolean; exposeScope: string }
set_mcp_enabled(input: { enabled: boolean; transport?: "http" | "stdio"; port?: number }): { endpoint: string; token: string } // FR-MCP-1/6
set_mcp_scope(input: { expose: "none" | "selected" | "all"; exposeRecordings?: boolean }): void // FR-MCP-3
mcp_access_log(input?: { limit?: number }): McpAccessEntry[] // audit (FR-MCP-5)
// (Layer 3, later) push handoff — spawn a local agent CLI / open a tracker issue from a brief.
run_agent(input: { briefId: string; tool: "claude" | "codex" | "opencode" | "copilot"; repoPath: string }): { ok: boolean } // FR-AGENT-1
create_issue_from_brief(input: { briefId: string; tracker: "github"; assignCopilot?: boolean }): { url: string } // FR-AGENT-2
// ---- Settings ----
// Settings gained `whisper_language: string | null` (T8.7, FR-TRX-4, M4.2) — the default
// transcription language applied at the next start_recording; null = auto-detect. Mirrors
// this doc's settings.json `transcription.language` (03-data-model.md).
get_settings(): Settings
update_settings(input: Partial<Settings>): Settings
// Reports the full egress allowlist so the UI can prove exactly what may leave the device (FR-SEC-2).
privacy_self_check(): {
llmEndpoint: string; llmIsLocal: boolean;
syncEnabled: boolean;
syncTargets: { name: string; host: string; thirdParty: boolean; tls: boolean }[];
allowlistedHosts: string[];
}
```
### Conventions
- Commands return promptly; anything long-running (recording, transcription, summary, model
download, PST import) reports progress/results through **events** below.
- `MeetingId` is a uuid string. `BackendId` ∈ `"npu" | "nvidia" | "amd" | "intel" | "cpu"`.
## 2. Tauri events (Rust → frontend)
```ts
"recording://state" { meetingId, state: "recording"|"paused"|"stopped"|"cancelled", elapsedMs }
"recording://level" { meetingId, rms: number, peak: number } // waveform (FR-CAP-5)
"recording://device" { meetingId, recovered: boolean, message: string } // capture device change (FR-CAP-6)
"recording://mic" { meetingId, muted: boolean } // mic mute toggled (FR-CAP-7)
"transcript://segment" { meetingId, segment: TranscriptSegment } // live segments (FR-TRX-2); may re-emit a committed segment with a refined `speaker` — replace by `segment.id`
"transcript://finalized" { meetingId, segmentCount }
"import://progress" { meetingId, phase: "prepare"|"transcribe"|"diarize"|"finalize", state: "active"|"done"|"error", elapsedMs: number|null, error: string|null } // background import_media tracker
"diarization://updated" { meetingId, speakers: SpeakerInfo[] } // post-pass AND live 15s provisional passes (FR-SPK); carries "You" once the mic voiceprint matches
"llm://token" { meetingId, text } // streamed summary (FR-LLM-4)
"llm://done" { meetingId, summary: SummaryFile } // full summary.json contents, not just a pointer
"model://progress" { id, receivedBytes, totalBytes }
"pst://progress" { processed, total }
"calendar://linked" { ok: boolean, error?: string } // MS Graph OAuth handshake settled (M4.4)
"calendar://progress" { processed, total } // MS Graph import (M4.4)
"hardware://changed" { active: BackendId, reason: string } // fallback occurred (FR-HW-4)
"device://level" { kind: "input"|"loopback", rms?, peak?, done?: boolean } // Settings device test meter; done=window ended
"stress://progress" { backend: string, model: string } // quick stress test, per pairing benchmarked
"recording://retention" { meetingId, record: boolean } // retention toggled (FR-REC-1/3)
"sync://job" { jobId, meetingId, targetId, artifact, status, bytesSent, bytesTotal } // FR-SYNC-5
"sync://done" { meetingId, targetId, uploaded: number, failed: number }
"mcp://access" { at, tool, meetingId?, client? } // agent read something (FR-MCP-5)
"agent://progress" { briefId, tool, line } // push run output (FR-AGENT-1)
"error" { kind, message, context? }
```
## 3. Internal Rust service traits
These define the seams that let engines be swapped without touching callers. Signatures are
indicative (async where I/O-bound).
```rust
// audio/mod.rs
pub trait AudioCapture: Send + Sync {
/// Begin WASAPI loopback capture, writing PCM to `wav_path`; frames also pushed to `sink`.
fn start(&self, wav_path: &Path, sink: FrameSink) -> Result<CaptureHandle, AudioError>;
/// Capture the user's microphone (FR-CAP-7); frames pushed to `sink`, no WAV.
fn start_microphone(&self, device_id: Option<&str>, sink: FrameSink) -> Result<CaptureHandle, AudioError>;
fn pause(&self, h: &CaptureHandle) -> Result<(), AudioError>;
fn resume(&self, h: &CaptureHandle) -> Result<(), AudioError>;
fn stop(&self, h: CaptureHandle) -> Result<CaptureSummary, AudioError>;
}
// When the mic is enabled, `spawn_mixer` sums the loopback + mic 16kHz-mono
// frames into the single transcription stream (`list_input_devices` enumerates
// mic devices, mirroring `list_audio_devices` for render devices).
// hardware/mod.rs
pub trait HardwareDetector: Send + Sync {
fn detect(&self) -> Vec<BackendInfo>; // ranked NPU→NVIDIA→AMD→Intel→CPU
fn best(&self, preferred: Option<BackendId>) -> BackendInfo;
}
// transcription/mod.rs
pub trait Transcriber: Send + Sync {
fn load(model: &Path, backend: BackendId) -> Result<Self, TrxError> where Self: Sized;
/// Stream interim + final segments for an audio window.
fn transcribe_stream(&self, audio: AudioWindow, out: SegmentSink) -> Result<(), TrxError>;
/// One-shot batch transcription (higher accuracy).
fn transcribe_file(&self, wav: &Path) -> Result<Vec<TranscriptSegment>, TrxError>;
}
// diarization/mod.rs
pub trait Diarizer: Send + Sync {
fn diarize(&self, wav: &Path) -> Result<Vec<SpeakerSpan>, DiarError>;
fn assign(&self, segments: &mut [TranscriptSegment], spans: &[SpeakerSpan]);
}
// storage/mod.rs (async, sqlx)
#[async_trait] pub trait Store: Send + Sync {
async fn create_meeting(&self, m: NewMeeting) -> Result<MeetingId, StoreError>;
async fn finalize_meeting(&self, id: &MeetingId, s: FinalizeMeeting) -> Result<(), StoreError>;
async fn list_meetings(&self, f: MeetingFilter) -> Result<Vec<MeetingListItem>, StoreError>;
async fn get_meeting(&self, id: &MeetingId) -> Result<Meeting, StoreError>;
async fn delete_meeting(&self, id: &MeetingId) -> Result<(), StoreError>;
async fn search(&self, q: &str) -> Result<Vec<SearchHit>, StoreError>;
async fn recover_scan(&self) -> Result<Vec<MeetingId>, StoreError>; // FR-REL-1
async fn enforce_retention(&self, policy: Retention) -> Result<u32, StoreError>;
}
// llm/mod.rs
#[async_trait] pub trait LlmProvider: Send + Sync {
async fn status(&self) -> LlmStatus; // reachable? local? models
async fn summarize(&self, prompt: Prompt, out: TokenSink) -> Result<Summary, LlmError>;
fn is_local(&self) -> bool; // FR-LLM-6 guard
}
// calendar/mod.rs
// `attendees()` (a second, separate trait method in the original design) was dropped — every
// source (PstSource, GraphSource) lists attendees inline per-appointment, so import() returns
// them together (see ADR-0008's update). CalImport gained `from`/`to` (M4.4) for a source that
// fetches by date range (Graph's calendarView); PstSource ignores them.
pub trait CalendarSource: Send + Sync {
fn import(&self, input: CalImport) -> Result<Vec<ImportedEvent>, CalError>; // pst|graph|ics
}
// notes/mod.rs
pub trait NotesRenderer: Send + Sync {
fn to_markdown(&self, t: &Transcript, speakers: &[SpeakerInfo], s: Option<&Summary>) -> String;
fn export(&self, md: &str, dest: &Path, fmt: ExportFormat) -> Result<PathBuf, NotesError>;
}
// MarkdownNotes::merge(segments, speakers, manual: &ManualNotes, summary, template) -> String is an
// inherent method (not part of the trait — only one renderer needs it): what `stop_recording` calls
// instead of `to_markdown` to fold manual_notes.json into the generated notes.md (see 03-data-model.md).
// sync/mod.rs
// One impl per provider; `WebDavTarget` covers Nextcloud/ownCloud/Cloudreve/Seafile/Synology.
// Secondary OAuth impls: OneDriveTarget, DropboxTarget, BoxTarget (ADR-0010).
#[async_trait] pub trait SyncTarget: Send + Sync {
fn kind(&self) -> SyncKind;
fn is_third_party(&self) -> bool; // false for self-hosted WebDAV
async fn test(&self) -> Result<(), SyncError>; // reachability + auth (FR-SYNC-4)
async fn ensure_dir(&self, remote_dir: &str) -> Result<(), SyncError>;
async fn exists(&self, remote_path: &str, sha256: &str) -> Result<bool, SyncError>; // skip-if-unchanged
/// Upload a file; resumable/chunked for large artifacts. Reports progress via `prog`.
async fn put(&self, local: &Path, remote_path: &str, prog: ProgressSink) -> Result<(), SyncError>;
}
// Owns the durable queue, retry/backoff, credential resolution, and TLS enforcement.
#[async_trait] pub trait SyncManager: Send + Sync {
async fn enqueue_meeting(&self, meeting_id: &MeetingId, target_id: Option<&str>) -> Result<(), SyncError>;
async fn pump(&self) -> Result<(), SyncError>; // drive pending jobs (called on finalize + startup + timer)
async fn status(&self, meeting_id: Option<&MeetingId>) -> Result<Vec<SyncJobInfo>, SyncError>;
async fn retry(&self, job_id: &str) -> Result<(), SyncError>;
}
// llm/mod.rs — `LlmProvider` (above) gains hosted impls behind the same trait (ADR-0011):
// OllamaProvider (local) · OpenAiCompatProvider (/v1/chat/completions) · AnthropicProvider (/v1/messages)
// `is_local()` stays the egress guard; hosted impls return false and require an API key from the keychain.
// mcp/mod.rs — WA as an MCP server (loopback, off by default). Tools-first (FR-MCP-2).
#[async_trait] pub trait McpServer: Send + Sync {
async fn start(&self, cfg: McpConfig) -> Result<McpHandle, McpError>; // returns endpoint + token
async fn stop(&self, h: McpHandle) -> Result<(), McpError>;
fn tools(&self) -> Vec<McpToolDescriptor>; // list_recent_meetings, get_transcript, get_action_items, get_feature_brief
}
// Builds the agent-ready spec from a transcript via the configured LlmProvider.
#[async_trait] pub trait FeatureBriefBuilder: Send + Sync {
async fn build(&self, meeting_id: &MeetingId, target_repo: Option<&str>) -> Result<FeatureBrief, BriefError>;
}
// agent/mod.rs — Layer 3 (later). Push handoff; one impl per CLI / tracker.
#[async_trait] pub trait AgentRunner: Send + Sync {
async fn run(&self, brief: &FeatureBrief, repo: &Path, out: LineSink) -> Result<RunOutcome, AgentError>; // claude -p / codex exec / …
}
#[async_trait] pub trait IssueTracker: Send + Sync {
async fn create_issue(&self, brief: &FeatureBrief, assign_copilot: bool) -> Result<String /*url*/, AgentError>;
}
```
## Versioning
- Command/event names and payload shapes are versioned implicitly by `schema` fields in persisted
JSON (`03-data-model.md`) and explicitly in a `CONTRACTS_VERSION` constant. Breaking a command
shape requires bumping it and updating the frontend client in the same change (see `CLAUDE.md`
"source of truth" rule).