Skip to content

feat(reply-drafts): Reply Drafts section for prepared replies#70

Open
yustme wants to merge 11 commits into
mainfrom
feat/reply-drafts
Open

feat(reply-drafts): Reply Drafts section for prepared replies#70
yustme wants to merge 11 commits into
mainfrom
feat/reply-drafts

Conversation

@yustme

@yustme yustme commented Jun 29, 2026

Copy link
Copy Markdown
Collaborator

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 on Scout/Proposals/:

  • Models ReplyDraft, DraftStatus (draft|sent|dismissed), DraftChannel (email|slack|linear|github|whatsapp).
  • ReplyDraftsParser (frontmatter + body), ReplyDraftsWriter (status rewrite via GuardedFileWrite + scoped git commit), ReplyDraftsDocumentService (FSEvents-backed, pending-count badge).
  • Views: RepliesView, ReplyDraftCardView, DraftStatusPill, ChannelBadge.
  • Wired into AppState (service + writer box, loaded at launch, badge reactivity), MainWindowView (SidebarItem.replyDrafts + detail route), SidebarView (badge row). New files land in the file-system-synchronized Scout / ScoutTests groups, so no project.pbxproj edits.

Contract

The drafts/<TAG>.md frontmatter (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 lowercase status: 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 test run on CI.

Companion

Paired with the scout-plugin PR (Raven-Scout/scout-plugin#175) that detects the loops and writes these draft files.

yustme added 2 commits June 29, 2026 23:40
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.
yustme added 9 commits June 30, 2026 10:26
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 AdamVyborny left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.yml adds a job that builds and uploads an unsigned Scout.app (CODE_SIGNING_ALLOWED=NO, then a manual xattr quarantine-clear). It's unrelated to Reply Drafts and obsolete if signed/notarized builds are now available. The body stresses "no project.pbxproj edits" 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 adds ReplyChatService (a per-draft AI chat) plus a Slack-send / Gmail-draft delivery path, both shelling out to the claude CLI. Proposals has 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 / the nonEmpty extension are a 3rd–4th byte-identical copy (already in PerFileItemParser and ProposalsParser). This wants one shared frontmatter parser.
  • ReplyDraftsWriter.rewriteFrontmatterStatus is byte-for-byte identical to ProposalsWriter.rewriteFrontmatterStatus (only the error enum name differs).
  • The serial tail-Task chain + GuardedFileWrite conflict handling is copied from ProposalsWriter.decide/perform (the comment even says "same guard as ProposalsWriter").
  • relativePathInRepo is a 4th private copy (ActionItemsWriter, PerFileItemWriter, ProposalsWriter).
  • ReplyDraftsDocumentService is a near-verbatim clone of ProposalsDocumentService (State enum, load/reload/reparse, FSEvents watch + 250ms debounce, pendingCount).
  • AppState.resolveClaudePath re-implements ClaudeLauncher.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. busyTag and deliveringTag are independent flags, so a chat + a delivery can spawn two concurrent claude agents against the same MCP/vault. And busyTag is a global gate: starting a chat on card A silently no-ops a chat send on card B — and drops the typed input (sendChat already cleared it) — with no feedback.
  • Chat state keyed by draft.tag, which isn't unique (falls back to the filename stem), while draft identity is fileURL. Colliding tags share threads/busyTag/deliveringTag.
  • Efficiency: reparse() does main-thread contentsOfDirectory + String(contentsOf:) + parse over the whole drafts/ dir on load and every debounced FSEvent (twice per app write); ReplyDraft.inputs recompiles+runs a regex ~3× per card render; the parser regexes recompile on every call (make them static let).
  • deliveryNote styling is re-derived by hasPrefix("Failed") || hasPrefix("Couldn't") instead of the ok: Bool the service already returns (the "Busy — try again" message would render as success).
  • Dead date frontmatter key in parseFile — the format only ever emits created:.
  • Stringly-typed contracts (channel/loop_type/status) fall back silently: an unknown future status: maps to .draft and 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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants