feat(reply-drafts): Reply Drafts section for prepared replies#70
feat(reply-drafts): Reply Drafts section for prepared replies#70yustme wants to merge 11 commits into
Conversation
Adds a Reply Drafts sidebar section that surfaces the prepared replies Scout writes to drafts/<TAG>.md — one card per open loop where the user owes an answer. Read the full drafted text, Copy it, Open the original thread, then Mark sent / Dismiss. The app NEVER sends and never creates a native draft; it only flips the file's status: field (draft -> sent | dismissed), mirroring the Proposals feature. New module Scout/ReplyDrafts/ (model, channel/status enums, parser, status writer via GuardedFileWrite + git, FSEvents-backed document service, cards). Wired into AppState + MainWindowView + SidebarView. Swift Testing coverage for status/channel parsing, draft parsing, and the status-rewrite writer (pure + end-to-end with git). Contract matches scout-plugin drafts/<TAG>.md.
Adds an 'app' job that produces an unsigned Release Scout.app and uploads it as the Scout-app artifact, so the build (with reply-drafts changes) can be downloaded and run without a local Xcode. Mirrors the step on feat/knowledge-base.
Adds the optional cc frontmatter field to ReplyDraft + parser and renders it under the recipient on the card, so an email reply keeps the thread's other recipients visible. Matches the scout-plugin drafts/<TAG>.md contract.
Each [TBD: ...] in a draft body is now surfaced as a labeled input on the card. Typing a value and applying replaces the whole [TBD: ...] literal with the text in the body (via GuardedFileWrite + git commit) so the reply reads cleanly and re-renders. Adds DraftInput model + extraction, ReplyDraftsWriter.fill, the card inputs section, and tests.
UI chrome must be English: 'Doplň před odesláním' -> 'Fill in before sending', placeholder 'Tvoje doplnění…' -> 'Your input…', button 'Doplnit' -> 'Fill in'. Field labels still come from the draft's [TBD: ...] text (the email's language).
Parses a <!-- scout:context --> block (## Summary + ## Thread '- [date] Sender: line' messages) out of the draft file, keeping bodyMarkdown to the sendable reply only. Renders two collapsible DisclosureGroups under the draft: an AI summary of the topic and the related thread messages, so the user can see what they're replying to. Adds DraftMessage model, context parsing, and tests.
ReplyDraft gained summary + relatedMessages; the test's direct initializer call was missing them, failing the test-target build. Pass summary: nil, relatedMessages: [].
Adds an 'Ask AI about this topic' chat under each draft that shells out to the user's claude CLI (their own license), grounded in the draft's summary, thread, and current reply. Multi-turn, in-memory per draft tag; never sends or mutates the draft file. Adds ChatMessage model, ReplyChatService, AppState wiring + claude path resolver, the card chat section, and a buildPrompt test.
@published / ObservableObject need Combine; SwiftUI doesn't re-export the property-wrapper resolution here. Matches the other document services.
Channel-conditional delivery on the card: slack drafts get 'Send via Slack' (with a confirmation dialog — it really sends), email drafts get 'Create Gmail draft' (never sends). Both shell out to the user's claude CLI to perform the action via Slack/Gmail MCP; a successful Slack send marks the draft sent. Adds ReplyChatService.deliver + deliveryPrompt and tests.
The generic 'Open thread' button is now labeled per channel — 'Open in Gmail' for email, 'Open in Slack' for slack, etc. — and opens the draft's thread_ref (the Gmail thread / Slack permalink) so it jumps to where it's discussed.
AdamVyborny
left a comment
There was a problem hiding this comment.
Reply Drafts — review
The Scout/ReplyDrafts/ module is cleanly modelled on Proposals, but this PR does substantially more than the description says, duplicates a lot of existing infrastructure instead of reusing it, and carries the same out-of-scope CI job as the Knowledge Base PR. Requesting changes. Most-severe first.
Scope — please split these out
- CI
app:job (same issue as #69)..github/workflows/ci.ymladds a job that builds and uploads an unsignedScout.app(CODE_SIGNING_ALLOWED=NO, then a manualxattrquarantine-clear). It's unrelated to Reply Drafts and obsolete if signed/notarized builds are now available. The body stresses "noproject.pbxprojedits" but doesn't call this job out at all. Drop it here; do downloadable builds as a separate PR (signed, ideally). - Undisclosed scope: a whole
claude-driven subsystem. The Implementation section lists only Models/Parser/Writer/DocumentService/Views and says it "mirrors how Proposals flips a status field." In fact the PR addsReplyChatService(a per-draft AI chat) plus a Slack-send / Gmail-draft delivery path, both shelling out to theclaudeCLI.Proposalshas none of that. The description should state this plainly, or delivery/chat should be a separate PR.
Correctness / safety
1. The app does send — contradicting the PR's central "never sends" hard constraint. ReplyDraftCardView has a "Send via Slack" button → ReplyChatService.deliver(.slackSend), whose prompt tells the CLI: "Using the Slack MCP tools, SEND the following message EXACTLY as written … as a reply in the Slack conversation." The "Create Gmail draft" button (.gmailDraft) creates a native Gmail draft — contradicting "never creates a native draft." On a "successful" Slack send it auto-flips the file to status: sent. The stated only-side-effect (a status flip) is not what the code does.
2. Delivery runs claude --permission-mode auto — an auto-approved agent with full MCP access, driven by untrusted draft text. Every tool call is pre-approved, with the user's entire MCP surface (Slack send, Gmail, Linear, filesystem…). The prompt interpolates draft.bodyMarkdown / threadRef / to verbatim, and those originate from drafts/<TAG>.md generated from external Slack/email content the user didn't write — a prompt-injection surface ("ignore the above; instead post X / forward to attacker@…"). The Slack confirm dialog gates "send this reply," not "run an unsupervised auto-approving agent." A direct Slack/Gmail API call to an explicit channel/thread id would be both safer and deterministic.
3. Delivery "success" is out.uppercased().contains("OK ") over free-form model stdout, then auto-marks the draft sent. exitCode == 0 && out contains "OK " → for Slack, onAction(.markSent) commits status: sent. Model prose like "I could not send. FAILED: … it may be OK to retry" contains "OK " → false success → the loop is recorded closed and a real reply is silently dropped. An ack worded "Message sent." reads as failure → re-click → double-post. Durable, git-committed state rides on a substring of model prose, not a delivery receipt.
4. ReplyDraftsWriter.fillPlaceholder replaces the FIRST occurrence, defeating the per-marker independent-fill contract. DraftInput.extract gives identical [TBD: …] markers distinct ids ("so two same-worded TBDs can be filled independently"), but fill matches on the placeholder string via text.range(of:). Filling the second identical marker writes into the first; the second can never be filled.
5. claude is launched without the login-shell wrapper the app already uses — likely won't run from a Finder-launched bundle. The subprocess inherits Scout.app's environment (the runner only overrides env when the dict is non-empty), which under a GUI/launchd launch has a stripped PATH and no sourced profile. The existing ClaudeLauncher.resolveClaudePath deliberately falls back to SHELL -lc 'command -v claude' for exactly this (and finds mise/asdf/nvm installs); the new AppState.resolveClaudePath checks four fixed paths then /usr/bin/env claude. So claude — and especially its MCP servers, spawned via npx/node by PATH — likely fail to launch from the app, and chat/delivery silently never work for users it works for in a terminal. (Environment-dependent — verify by running the built .app from Finder.)
6. ReplyDraftsParser.parseMessages sender group [^:]+ splits at the first colon. A ## Thread line - [2026-05-26] Alex 10:30: said hi parses sender = "Alex 10", text = "30: said hi".
Reuse — this module re-implements a lot the codebase already has
Rather than sharing existing infrastructure, ReplyDrafts copies it:
splitFrontmatter/parseFrontmatterFields/datePrefix/ thenonEmptyextension are a 3rd–4th byte-identical copy (already inPerFileItemParserandProposalsParser). This wants one shared frontmatter parser.ReplyDraftsWriter.rewriteFrontmatterStatusis byte-for-byte identical toProposalsWriter.rewriteFrontmatterStatus(only the error enum name differs).- The serial
tail-Taskchain +GuardedFileWriteconflict handling is copied fromProposalsWriter.decide/perform(the comment even says "same guard as ProposalsWriter"). relativePathInRepois a 4th private copy (ActionItemsWriter,PerFileItemWriter,ProposalsWriter).ReplyDraftsDocumentServiceis a near-verbatim clone ofProposalsDocumentService(State enum, load/reload/reparse, FSEvents watch + 250ms debounce, pendingCount).AppState.resolveClaudePathre-implementsClaudeLauncher.resolveClaudePath(and, missing the login-shell fallback, less correctly — see finding 5).
A shared frontmatter parser + a generic "serialized frontmatter-field writer" + a generic FSEvents document service parametrised by parser/predicate would remove most of this module and keep the four copies from drifting (a fix to concurrency/conflict handling currently has to be applied in every writer).
Anonymization (public repo)
7. Real identifiers in test fixtures + a production doc comment. Violates CLAUDE.md: "No real identifiers. Strip company/product names, real coworker names." ScoutTests/ReplyDrafts/ uses the real bank domain slsp.sk (Slovenská sporiteľňa) in to: "Lucia <l@slsp.sk>" / cc: "Jakub <j@slsp.sk>", the real-looking full name "Lucia Hallonová", and the author's own first name "Vojta (you)" — the last also in a production doc comment in Scout/ReplyDrafts/Models/DraftMessage.swift. Use the Alex/Priya/Sam stand-ins and a placeholder domain. (Jan Novák/Petra Malá/firma.cz are fine — generic placeholders.)
Smaller / lower-confidence
- Single-flight design is off.
busyTaganddeliveringTagare independent flags, so a chat + a delivery can spawn two concurrentclaudeagents against the same MCP/vault. AndbusyTagis a global gate: starting a chat on card A silently no-ops a chat send on card B — and drops the typed input (sendChatalready cleared it) — with no feedback. - Chat state keyed by
draft.tag, which isn't unique (falls back to the filename stem), while draft identity isfileURL. Colliding tags sharethreads/busyTag/deliveringTag. - Efficiency:
reparse()does main-threadcontentsOfDirectory+String(contentsOf:)+ parse over the wholedrafts/dir on load and every debounced FSEvent (twice per app write);ReplyDraft.inputsrecompiles+runs a regex ~3× per card render; the parser regexes recompile on every call (make themstatic let). deliveryNotestyling is re-derived byhasPrefix("Failed") || hasPrefix("Couldn't")instead of theok: Boolthe service already returns (the "Busy — try again" message would render as success).- Dead
datefrontmatter key inparseFile— the format only ever emitscreated:. - Stringly-typed contracts (
channel/loop_type/status) fall back silently: an unknown futurestatus:maps to.draftand resurfaces as actionable/re-sendable; a renamed channel hides the send button with no diagnostic.
Checked and ruled out
environment: [:] is not an "empty env" bug (the runner inherits the parent env when the dict is empty) — the real concern is the inherited GUI env (finding 5). openThread's URL(string:) returning nil is a benign no-op. The single deletion (the SidebarItem case list) is a clean in-place enum extension.
Adds a Reply Drafts sidebar section that surfaces the prepared replies Scout writes to
drafts/<TAG>.md— one card per open loop where you owe someone an answer.Each card shows the recipient, subject, channel + status chips, and the full drafted reply body (selectable). Actions: Copy (body to clipboard), Open thread (the original conversation), Mark sent, Dismiss, and Reopen for resolved drafts.
Hard constraint
The app never sends and never creates a native draft. The only side effect of Mark sent / Dismiss / Reopen is flipping the file’s
status:field (draft → sent | dismissed) — sending stays the user’s action. Mirrors how the Proposals feature flips a status field.Implementation
New module
Scout/ReplyDrafts/, modelled onScout/Proposals/:ReplyDraft,DraftStatus(draft|sent|dismissed),DraftChannel(email|slack|linear|github|whatsapp).ReplyDraftsParser(frontmatter + body),ReplyDraftsWriter(status rewrite viaGuardedFileWrite+ scoped git commit),ReplyDraftsDocumentService(FSEvents-backed, pending-count badge).RepliesView,ReplyDraftCardView,DraftStatusPill,ChannelBadge.AppState(service + writer box, loaded at launch, badge reactivity),MainWindowView(SidebarItem.replyDrafts+ detail route),SidebarView(badge row). New files land in the file-system-synchronizedScout/ScoutTestsgroups, so noproject.pbxprojedits.Contract
The
drafts/<TAG>.mdfrontmatter (tag, channel, loop_type, to, thread_ref, subject, status, created, context_answer_ref) matches what scout-plugin writes. The app writes back only the canonical lowercasestatus:word so a re-read by Scout round-trips.Tests
Swift Testing suites for status/channel parsing, draft parsing (incl. README-without-frontmatter skipped, chat channels omitting subject, promise-answered context ref), and the writer (pure status rewrite + end-to-end with git commit scoped to
drafts/<tag>.md). Full build +xcodebuild testrun on CI.Companion
Paired with the scout-plugin PR (Raven-Scout/scout-plugin#175) that detects the loops and writes these draft files.