Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 45 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
# CLAUDE.md

Working notes for AI agents in this repo (the macOS desktop Scout app).

## Fixtures must be anonymized — this repo and its two siblings are public

Scout runs against a real person's vault, so anything lifted from it into a
fixture or an inline test string **must be scrubbed before it lands**. All three
Scout repos — this one, `scout-iOS-app`, and `scout-plugin` — are public.

- **No real identifiers.** Strip company/product names, real coworker names, real
Linear IDs, GitHub repos, and Slack workspaces/channels. Use the shared
stand-ins so fixtures stay internally consistent:
- People: `Alex` / `Priya` / `Sam`; comment/proposal author `alex` / `Alex`.
- Linear: `PROJ-1234` (use neutral team prefixes like `OPS-`, `DESK-`, `TEAM-`
when you need variety) — never the real team prefixes (`AI-`, `KAI-`, `ST-`, …).
- GitHub: `example-org/<repo>`.
- Slack: `acme-co.slack.com/archives/C0123456789/p1700000000000000`.
- Vendors/products: a generic noun ("the demo", "the tracing job"), not the brand.
- **Anonymize content, not structure.** Keep the load-bearing tokens the parser is
tested on — the synthetic `[#TAG]` short-prefixes (`MIRO`, `AI3026`, `RSM`,
`5864M`…), `**bold**`, `_(italic)_`, `[[wikilinks]]`, ` — ` separators,
`` `code` ``. Only swap the words around them.
- **Preserve legitimate attribution** — these are NOT leaks, leave them: the
`pyproject`/`marketplace.json` owner, `LICENSE`, and the project's own
`github.com/<org>/…` URLs.

### `parser-corpus.json` is ONE byte-identical file living in three repos

`ScoutTests/Fixtures/parser-corpus.json` is byte-identical to the **canonical**
copy in `scout-plugin` and the copy in `scout-iOS-app`, and is checksum-guarded on
both the Swift and Python sides — so you cannot edit just one copy. On any change
(anonymizing counts):

1. Edit the corpus; keep every `expected` field consistent with the parser rules
(`ParserContractTests` is the judge).
2. Copy it byte-for-byte into the sibling checkouts (cloned alongside this repo):
- `../scout-plugin/engine/tests/fixtures/contract/parser-corpus.json` (canonical)
- `../scout-ios/ScoutMobileTests/Fixtures/parser-corpus.json`
3. Update BOTH checksum guards to the new `shasum -a 256` of the file:
- `canonicalSHA256` in `ScoutTests/ActionItems/ParserContractTests.swift`
- `EXPECTED_SHA256` in `../scout-plugin/engine/tests/unit/test_parser_corpus_checksum.py`
4. Verify all three: this repo's `ParserContractTests` (on `platform=macOS`),
scout-iOS `ParserContractTests`, and plugin
`pytest tests/unit/test_parser_contract.py tests/unit/test_parser_corpus_checksum.py`.
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -133,4 +133,4 @@ Scout is local-first and collects no data of its own — the macOS app only read
- **Terms of Use** — https://raven-scout.github.io/scout-plugin/terms.html
- **[Security Policy](https://github.com/Raven-Scout/.github/blob/main/SECURITY.md)** · **[Code of Conduct](https://github.com/Raven-Scout/.github/blob/main/CODE_OF_CONDUCT.md)**

Scout is an independent project, not affiliated with Anthropic, Microsoft, Keboola, or any other company.
Scout is an independent project, not affiliated with Anthropic, Microsoft, or any other company.
2 changes: 1 addition & 1 deletion Scout/ActionItems/ActionItemsParser.swift
Original file line number Diff line number Diff line change
Expand Up @@ -439,7 +439,7 @@ extension ActionItemsParser {
// Sub-bullet comment line attached to the last task: scoutctl
// writes ` - <author>: <text>`. Distinct from the blockquote
// form above. Match must run BEFORE the bare-bullet `bulletRe`
// path so ` - jordan: hello` becomes a comment rather than a
// path so ` - alex: hello` becomes a comment rather than a
// sub-task body.
if inSection,
let last = currentTasks.last,
Expand Down
2 changes: 1 addition & 1 deletion Scout/ActionItems/Views/SnoozePopoverView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -121,7 +121,7 @@ struct SnoozePopoverView: View {
.buttonStyle(.plainHit)
.foregroundStyle(.secondary)
// Matches the unstyled "Send" button in
// CommentComposerView — Adam's UI pass (eb88094) targeted
// CommentComposerView — Alex's UI pass (eb88094) targeted
// toggles/segmented controls, leaving primary-action buttons
// on the system style. Stay consistent with that.
Button("Snooze") { commitCustom() }
Expand Down
2 changes: 1 addition & 1 deletion Scout/Proposals/Models/ProposalStatus.swift
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import Foundation
/// `Applied`. The app only ever writes `Approved` / `Rejected` — applying the
/// underlying SKILL.md change stays with the dreaming run.
nonisolated enum ProposalStatus: Equatable, Sendable {
/// `Proposed (awaiting Adam approval)` — needs an explicit decision.
/// `Proposed (awaiting Alex approval)` — needs an explicit decision.
case proposed
/// `Pending (auto-apply after <date>)` — opt-out; auto-applies unless
/// rejected. `autoApplyDate` is the ISO date when present.
Expand Down
2 changes: 1 addition & 1 deletion Scout/Services/SessionLogService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -593,7 +593,7 @@ extension RunType {
/// 12h dreaming) were way too loose and produced multi-hour false
/// "running" badges in the Now strip and Sessions list. CC-1/CC-5.
///
/// Observed P95s (May 2026 on Jordan's box):
/// Observed P95s (May 2026 on Alex's box):
/// briefing: ~6 min · consolidation: ~3 min
/// dreaming: ~25 min · research: ~40 min
/// Cutoffs leave ~3× headroom so a legitimately long run still resolves
Expand Down
2 changes: 1 addition & 1 deletion ScoutTests/ActionItems/ActionItemsParserTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -173,7 +173,7 @@ struct ActionItemsParserTests {

let withComment = Self.stableDoc.replacingOccurrences(
of: "- [ ] **First task** — needs attention",
with: "- [ ] **First task** — needs attention\n - jordan: looking into it"
with: "- [ ] **First task** — needs attention\n - alex: looking into it"
)
let after = try ActionItemsParser.parse(
text: withComment, sourceURL: Self.stableURL, sourceBytes: withComment.utf8.count)
Expand Down
8 changes: 4 additions & 4 deletions ScoutTests/ActionItems/ActionItemsWriterTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ struct ActionItemsWriterTests {
subject: "Engage on PROJ-123",
shortPrefix: nil,
text: "Paging reviewer.",
author: "jordan"
author: "alex"
), displayedDate: date)

let call = try #require(await recorder.calls.first)
Expand All @@ -29,7 +29,7 @@ struct ActionItemsWriterTests {
"action-items", "add-comment",
"/tmp/Scout/action-items/action-items-2026-04-20.md",
"--subject", "Engage on PROJ-123",
"--comment", "jordan: Paging reviewer."
"--comment", "alex: Paging reviewer."
])
}

Expand Down Expand Up @@ -256,13 +256,13 @@ struct ActionItemsWriterTests {
}

@Test func withShortPrefixReplacesPrefixPreservingPayload() {
let op = WriteOp.addComment(subject: "Subj", shortPrefix: nil, text: "hi", author: "jordan")
let op = WriteOp.addComment(subject: "Subj", shortPrefix: nil, text: "hi", author: "alex")
let promoted = op.withShortPrefix("AB12")
#expect(promoted.shortPrefix == "AB12")
#expect(promoted.subject == "Subj")
if case .addComment(_, _, let text, let author) = promoted {
#expect(text == "hi")
#expect(author == "jordan")
#expect(author == "alex")
} else {
Issue.record("case changed unexpectedly")
}
Expand Down
4 changes: 2 additions & 2 deletions ScoutTests/ActionItems/ClaudeLauncherPromptTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -27,15 +27,15 @@ struct ClaudeLauncherPromptTests {
let task = makeTask(
plainSubject: "Investigate pager storm",
comments: [
TaskComment(author: "jordan", timestamp: "2026-04-20 10:00 AM ET",
TaskComment(author: "alex", timestamp: "2026-04-20 10:00 AM ET",
text: "Saw three alerts in ten minutes."),
TaskComment(author: "priya", timestamp: "",
text: "Probably related to the queue drain we shipped."),
]
)
let out = ClaudeLauncher.prompt(for: task)
#expect(out.contains("Prior comments:"))
#expect(out.contains("- jordan (2026-04-20 10:00 AM ET): Saw three alerts in ten minutes."))
#expect(out.contains("- alex (2026-04-20 10:00 AM ET): Saw three alerts in ten minutes."))
#expect(out.contains("- priya: Probably related to the queue drain we shipped."))
}

Expand Down
20 changes: 10 additions & 10 deletions ScoutTests/ActionItems/DeepLinkDetectionTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -5,17 +5,17 @@ import Foundation
@Suite("Deep link detection")
struct DeepLinkDetectionTests {
@Test func detectsLinearIDsAcrossAllPrefixes() {
let text = "Blocked by [[AI-2879]] and ST-3853; see SUPPORT-15915, LDRS-321, KAI-12, DATA-42."
let text = "Blocked by [[PROJ-2879]] and OPS-3853; see DESK-15915, TEAM-321, PLAT-12, META-42."
let links = ActionItemsParser.detectDeepLinks(in: text)
let linearIDs = links.compactMap { if case .linear(let id) = $0 { return id } else { return nil } }
#expect(linearIDs == ["AI-2879", "ST-3853", "SUPPORT-15915", "LDRS-321", "KAI-12", "DATA-42"])
#expect(linearIDs == ["PROJ-2879", "OPS-3853", "DESK-15915", "TEAM-321", "PLAT-12", "META-42"])
}

@Test func dedupesRepeatedLinearID() {
let text = "[[AI-2619]] comment; [[AI-2619]] again; AI-2619 third mention."
let text = "[[PROJ-2619]] comment; [[PROJ-2619]] again; PROJ-2619 third mention."
let links = ActionItemsParser.detectDeepLinks(in: text)
#expect(links.count == 1)
if case .linear(let id) = links.first! { #expect(id == "AI-2619") } else { Issue.record("expected linear") }
if case .linear(let id) = links.first! { #expect(id == "PROJ-2619") } else { Issue.record("expected linear") }
}

@Test func detectsGitHubPR() {
Expand All @@ -39,14 +39,14 @@ struct DeepLinkDetectionTests {

@Test func detectsSchemelessSlackThread() {
// Sessions sometimes write the source as a bare host with no scheme, e.g.
// "Slack attachment `keboolaglobal.slack.com/archives/C094NK1JPJB/p1779784363387259`".
let text = "Slack attachment keboolaglobal.slack.com/archives/C094NK1JPJB/p1779784363387259"
// "Slack attachment `acme-co.slack.com/archives/C01234ABCDE/p1700000000123456`".
let text = "Slack attachment acme-co.slack.com/archives/C01234ABCDE/p1700000000123456"
let links = ActionItemsParser.detectDeepLinks(in: text)
#expect(links.count == 1)
if case .slackThread(let url) = links.first! {
// The scheme must be normalized to https so the link is openable.
#expect(url.scheme == "https")
#expect(url.absoluteString == "https://keboolaglobal.slack.com/archives/C094NK1JPJB/p1779784363387259")
#expect(url.absoluteString == "https://acme-co.slack.com/archives/C01234ABCDE/p1700000000123456")
} else {
Issue.record("expected slackThread")
}
Expand All @@ -67,11 +67,11 @@ struct DeepLinkDetectionTests {
}

@Test func preservesDetectionOrder() {
let text = "[[AI-2879]] then https://github.com/acme-co/api-kit/pull/68 then AI-3007."
let text = "[[PROJ-2879]] then https://github.com/acme-co/api-kit/pull/68 then PROJ-3007."
let links = ActionItemsParser.detectDeepLinks(in: text)
#expect(links.count == 3)
if case .linear(let a) = links[0] { #expect(a == "AI-2879") } else { Issue.record() }
if case .linear(let a) = links[0] { #expect(a == "PROJ-2879") } else { Issue.record() }
if case .githubPR = links[1] {} else { Issue.record() }
if case .linear(let b) = links[2] { #expect(b == "AI-3007") } else { Issue.record() }
if case .linear(let b) = links[2] { #expect(b == "PROJ-3007") } else { Issue.record() }
}
}
16 changes: 8 additions & 8 deletions ScoutTests/ActionItems/GitHubRefLinkifierTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -5,18 +5,18 @@ import Foundation
@Suite("GitHub ref linkifier")
struct GitHubRefLinkifierTests {
@Test func linkifiesQualifiedRef() {
let out = GitHubRefLinkifier.linkify("See keboola/mcp-server#553 for details.")
#expect(out == "See [keboola/mcp-server#553](https://github.com/keboola/mcp-server/issues/553) for details.")
let out = GitHubRefLinkifier.linkify("See example-org/mcp-server#553 for details.")
#expect(out == "See [example-org/mcp-server#553](https://github.com/example-org/mcp-server/issues/553) for details.")
}

@Test func linkifiesBareRefsWhenSingleRepoInferable() {
// The issue #17 example: bare #NNN refs plus a single repo slug mention.
let out = GitHubRefLinkifier.linkify(
"Triage mcp-server review requests — #555 (bump GH Actions), #498, #553 in keboola/mcp-server"
"Triage mcp-server review requests — #555 (bump GH Actions), #498, #553 in example-org/mcp-server"
)
#expect(out.contains("[#555](https://github.com/keboola/mcp-server/issues/555)"))
#expect(out.contains("[#498](https://github.com/keboola/mcp-server/issues/498)"))
#expect(out.contains("[#553](https://github.com/keboola/mcp-server/issues/553)"))
#expect(out.contains("[#555](https://github.com/example-org/mcp-server/issues/555)"))
#expect(out.contains("[#498](https://github.com/example-org/mcp-server/issues/498)"))
#expect(out.contains("[#553](https://github.com/example-org/mcp-server/issues/553)"))
}

@Test func leavesBareRefsPlainWhenNoRepo() {
Expand Down Expand Up @@ -69,10 +69,10 @@ struct GitHubRefLinkifierTests {
}

@Test func doesNotTouchWikilink() {
let input = "Context [[issue-tracker]] for keboola/mcp-server#1."
let input = "Context [[issue-tracker]] for example-org/mcp-server#1."
let out = GitHubRefLinkifier.linkify(input)
#expect(out.contains("[[issue-tracker]]"))
#expect(out.contains("[keboola/mcp-server#1](https://github.com/keboola/mcp-server/issues/1)"))
#expect(out.contains("[example-org/mcp-server#1](https://github.com/example-org/mcp-server/issues/1)"))
}

@Test func doesNotTouchInlineCode() {
Expand Down
26 changes: 13 additions & 13 deletions ScoutTests/ActionItems/MatchableSubjectTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,10 @@ struct MatchableSubjectTests {
// italic body and scoutctl fails to substring-match it. Bold-only
// matches reliably.
let task = make(
subject: "**🔥 🆕 Update kai-pricing-calculator-app with per-client conversion levers + margin maximizer** _(net-new from Kai's pricing meeting 6-7 AM ET; Jordan already iterating during meeting)_",
plainSubject: "🔥 🆕 Update kai-pricing-calculator-app with per-client conversion levers + margin maximizer _(net-new from Kai's pricing meeting 6-7 AM ET; Jordan already iterating during meeting)_"
subject: "**🔥 🆕 Update the pricing calculator app with per-client conversion levers + margin maximizer** _(net-new from the pricing review 6-7 AM ET; Alex already iterating during meeting)_",
plainSubject: "🔥 🆕 Update the pricing calculator app with per-client conversion levers + margin maximizer _(net-new from the pricing review 6-7 AM ET; Alex already iterating during meeting)_"
)
#expect(task.matchableSubject == "🔥 🆕 Update kai-pricing-calculator-app with per-client conversion levers + margin maximizer")
#expect(task.matchableSubject == "🔥 🆕 Update the pricing calculator app with per-client conversion levers + margin maximizer")
}

@Test func preservesInnerMarkdownInBoldPortion() {
Expand All @@ -33,10 +33,10 @@ struct MatchableSubjectTests {
// so we have to keep it too — stripping it would yield a substring
// that doesn't exist in the title.
let task = make(
subject: "**🔥 🆕 Close [PR #5526 (AI-3079 sandboxId metadata)](https://github.com/keboola/ui/pull/5526) with re-implement-on-OTel note** _(promoted 7:04 AM ET 5/20…)_",
plainSubject: "🔥 🆕 Close PR #5526 (AI-3079 sandboxId metadata) with re-implement-on-OTel note _(promoted 7:04 AM ET 5/20…)_"
subject: "**🔥 🆕 Close [PR #5526 (PROJ-3079 sandboxId metadata)](https://github.com/example-org/ui/pull/5526) with re-implement-on-OTel note** _(promoted 7:04 AM ET 5/20…)_",
plainSubject: "🔥 🆕 Close PR #5526 (PROJ-3079 sandboxId metadata) with re-implement-on-OTel note _(promoted 7:04 AM ET 5/20…)_"
)
#expect(task.matchableSubject == "🔥 🆕 Close [PR #5526 (AI-3079 sandboxId metadata)](https://github.com/keboola/ui/pull/5526) with re-implement-on-OTel note")
#expect(task.matchableSubject == "🔥 🆕 Close [PR #5526 (PROJ-3079 sandboxId metadata)](https://github.com/example-org/ui/pull/5526) with re-implement-on-OTel note")
}

@Test func stripsPriorityEmojiInsideBold() {
Expand All @@ -47,28 +47,28 @@ struct MatchableSubjectTests {
// urgent task with a markdown link in the title. Mirror scoutctl's
// cleanup here.
let task = make(
subject: "[#OIDC-MERGE] **🔴 Merge [mcp-server PR #546](https://github.com/keboola/mcp-server/pull/546) (AI-3295)** _(APPROVED…)_",
plainSubject: "🔴 Merge mcp-server PR #546 (AI-3295) _(APPROVED…)_"
subject: "[#OIDC-MERGE] **🔴 Merge [mcp-server PR #546](https://github.com/example-org/mcp-server/pull/546) (PROJ-3295)** _(APPROVED…)_",
plainSubject: "🔴 Merge mcp-server PR #546 (PROJ-3295) _(APPROVED…)_"
)
#expect(task.matchableSubject == "Merge [mcp-server PR #546](https://github.com/keboola/mcp-server/pull/546) (AI-3295)")
#expect(task.matchableSubject == "Merge [mcp-server PR #546](https://github.com/example-org/mcp-server/pull/546) (PROJ-3295)")
}

@Test func stripsLeadingStatusEmoji() {
// STATUS_EMOJI (✅/🔄/❓/⬜) is anchored to the start of scoutctl's
// cleaned title. If Scout extracts a bold portion that begins with
// one, drop it so the substring matches.
let task = make(
subject: "**✅ DONE 12:01 PM ET** — Jordan merged himself.",
plainSubject: "✅ DONE 12:01 PM ET — Jordan merged himself."
subject: "**✅ DONE 12:01 PM ET** — Alex merged himself.",
plainSubject: "✅ DONE 12:01 PM ET — Alex merged himself."
)
#expect(task.matchableSubject == "DONE 12:01 PM ET")
}

@Test func unwrapsStrikethrough() {
// STRIKETHROUGH `~~foo~~` is reduced to `foo` in scoutctl's title.
let task = make(
subject: "**~~Send report~~** _(superseded by Jordan)_",
plainSubject: "Send report _(superseded by Jordan)_"
subject: "**~~Send report~~** _(superseded by Alex)_",
plainSubject: "Send report _(superseded by Alex)_"
)
#expect(task.matchableSubject == "Send report")
}
Expand Down
2 changes: 1 addition & 1 deletion ScoutTests/ActionItems/ParserContractTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ struct ParserContractTests {
/// On an intentional corpus change: re-copy the canonical corpus into both
/// repos, then update this digest to the output of
/// `shasum -a 256 ScoutTests/Fixtures/parser-corpus.json`.
static let canonicalSHA256 = "4ebe8ae34a5b945bb5165ebd6bb6b818986c2cafec0ad30910bfd3fcb66e21a1"
static let canonicalSHA256 = "745dc8f886c52cd3a2273a2f5fd76934782492b159a6f63ab0d9e6978114511f"

struct Corpus: Decodable {
let entries: [Entry]
Expand Down
2 changes: 1 addition & 1 deletion ScoutTests/ActionItems/ShortPrefixTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -100,7 +100,7 @@ struct ActionItemsParseWithPrefixTests {
## 🔴 Urgent

- [ ] [#ABCD] **A task** body
> jordan: a blockquote comment
> alex: a blockquote comment
"""
let doc = try ActionItemsParser.parse(
text: md,
Expand Down
Loading