# Findings Store Implementation Plan > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** Give every ClaudeDo project a `.claudedo/` folder of short, durable "trap" findings that agents write via one MCP tool and read via a system-prompt pointer, so a known pitfall costs a file read instead of a wasted run. **Architecture:** A pure-filesystem `FindingsStore` owns `/.claudedo/` (one file per finding under `traps/`, a rebuilt `INDEX.md` routing layer). A `FindingsStoreLocator` maps an MCP call to a working directory — from the run's task for autonomous runs, from an explicit `list` argument for interactive sessions. Two thin `save_finding` tool wrappers (one per MCP surface) delegate to both. `TaskRunner` appends one sentence pointing at the index. Reading is plain `Read` on the markdown; there is no read tool. **Tech Stack:** .NET 8, ASP.NET Core, `ModelContextProtocol.Server`, EF Core + SQLite, xUnit, Avalonia 12 / CommunityToolkit.Mvvm. **Spec:** `docs/superpowers/specs/2026-08-10-findings-store-design.md` --- ## File Structure **Create** - `src/ClaudeDo.Worker/Findings/FindingsStore.cs` — filesystem I/O: write `traps/.md`, rebuild `INDEX.md`, slug validation, capacity warning. No git, no DB. - `src/ClaudeDo.Worker/Findings/Interfaces/IFindingsStore.cs` - `src/ClaudeDo.Worker/Findings/FindingsStoreLocator.cs` — "which working directory does this call target?" (task context vs `list` argument). No filesystem I/O. - `src/ClaudeDo.Worker/Findings/Interfaces/IFindingsStoreLocator.cs` - `src/ClaudeDo.Worker/Git/GitExcludeWriter.cs` — `.git/info/exclude` append, extracted from `SessionSkillSeeder` so both callers share it. - `src/ClaudeDo.Worker/External/FindingsMcpTools.cs` — `save_finding` on the always-on server (`list` argument). - `src/ClaudeDo.Worker/Runner/TaskRunFindingsMcpTools.cs` — `save_finding` on the in-task server (task context). - `tests/ClaudeDo.Worker.Tests/Findings/FindingsStoreTests.cs` - `tests/ClaudeDo.Worker.Tests/Findings/FindingsStoreLocatorTests.cs` - `src/ClaudeDo.Data/Migrations/_AddFindingsTracked.cs` (generated) **Modify** - `src/ClaudeDo.Worker/Skills/SessionSkillSeeder.cs` — delegate to `GitExcludeWriter`. - `src/ClaudeDo.Data/Models/ListEntity.cs` — add `FindingsTracked`. - `src/ClaudeDo.Data/Configuration/ListConfiguration.cs` — map the column. - `src/ClaudeDo.Worker/Program.cs:176-185` (in-task server) and `:302-330` (external server) — DI + `WithTools`. - `src/ClaudeDo.Worker/Runner/TaskRunner.cs:575-576` — add the pointer layer. - `src/ClaudeDo.Ui/ViewModels/Modals/ListSettingsModalViewModel.cs`, `src/ClaudeDo.Ui/Views/Modals/ListSettingsModalView.axaml` — the toggle. - `src/ClaudeDo.Ui/ViewModels/Islands/ListsIslandViewModel.cs` — "open findings" command. - `src/ClaudeDo.Localization/locales/en.json`, `de.json` — keys in parity. **Deliberately absent:** no `maps/` writer, no read tool, no retro-distill, no hit counter (spec §8). --- ### Task 1: FindingsStore — write a finding, rebuild the index **Files:** - Create: `src/ClaudeDo.Worker/Findings/Interfaces/IFindingsStore.cs` - Create: `src/ClaudeDo.Worker/Findings/FindingsStore.cs` - Test: `tests/ClaudeDo.Worker.Tests/Findings/FindingsStoreTests.cs` The store is deliberately git-free and DB-free: callers hand in the head commit and source task id. That keeps this class trivially testable against a temp directory. The index is **rebuilt from the files on disk** on every save, never patched incrementally. That way a finding the user deleted or renamed in VSCode self-heals on the next write. - [ ] **Step 1: Write the failing tests** ```csharp using ClaudeDo.Worker.Findings; namespace ClaudeDo.Worker.Tests.Findings; public sealed class FindingsStoreTests : IDisposable { private readonly string _root; public FindingsStoreTests() { _root = Path.Combine(Path.GetTempPath(), "cdo-findings-" + Guid.NewGuid().ToString("N")); Directory.CreateDirectory(_root); } public void Dispose() { try { Directory.Delete(_root, recursive: true); } catch { /* best effort */ } } private static FindingInput Input(string slug, string title = "Something is not what it looks like") => new(slug, title, "Body line one.\n\nBody line two.", "src/ClaudeDo.Worker", "task-1", "abc1234"); [Fact] public async Task SaveAsync_WritesFindingFileWithFrontmatter() { var store = new FindingsStore(); var outcome = await store.SaveAsync(_root, Input("conpty-arg-quoting"), CancellationToken.None); Assert.True(outcome.Created); Assert.Equal("conpty-arg-quoting", outcome.Slug); var text = await File.ReadAllTextAsync(Path.Combine(_root, ".claudedo", "traps", "conpty-arg-quoting.md")); Assert.StartsWith("---\n", text.Replace("\r\n", "\n")); Assert.Contains("slug: conpty-arg-quoting", text); Assert.Contains("scope: src/ClaudeDo.Worker", text); Assert.Contains("source-task: task-1", text); Assert.Contains("verified-against: abc1234", text); Assert.Contains("# Something is not what it looks like", text); Assert.Contains("Body line one.", text); } [Fact] public async Task SaveAsync_SameSlugOverwritesInsteadOfCreatingASecondFile() { var store = new FindingsStore(); await store.SaveAsync(_root, Input("dup", "First title"), CancellationToken.None); var outcome = await store.SaveAsync(_root, Input("dup", "Second title"), CancellationToken.None); Assert.False(outcome.Created); Assert.Equal(1, outcome.TotalFindings); var files = Directory.GetFiles(Path.Combine(_root, ".claudedo", "traps")); Assert.Single(files); var text = await File.ReadAllTextAsync(files[0]); Assert.Contains("# Second title", text); Assert.DoesNotContain("# First title", text); } [Fact] public async Task SaveAsync_RebuildsIndexFromDiskSortedBySlug() { var store = new FindingsStore(); await store.SaveAsync(_root, Input("zulu", "Zulu trap"), CancellationToken.None); await store.SaveAsync(_root, Input("alpha", "Alpha trap"), CancellationToken.None); var index = await File.ReadAllTextAsync(Path.Combine(_root, ".claudedo", "INDEX.md")); var lines = index.Replace("\r\n", "\n").Split('\n'); var alphaAt = Array.FindIndex(lines, l => l.Contains("alpha")); var zuluAt = Array.FindIndex(lines, l => l.Contains("zulu")); Assert.True(alphaAt >= 0 && zuluAt >= 0); Assert.True(alphaAt < zuluAt); Assert.Contains("- [alpha](traps/alpha.md) — Alpha trap", index); } [Fact] public async Task SaveAsync_IndexDropsFindingsDeletedOnDisk() { var store = new FindingsStore(); await store.SaveAsync(_root, Input("gone", "Gone trap"), CancellationToken.None); await store.SaveAsync(_root, Input("stays", "Stays trap"), CancellationToken.None); File.Delete(Path.Combine(_root, ".claudedo", "traps", "gone.md")); await store.SaveAsync(_root, Input("stays", "Stays trap"), CancellationToken.None); var index = await File.ReadAllTextAsync(Path.Combine(_root, ".claudedo", "INDEX.md")); Assert.DoesNotContain("gone", index); Assert.Contains("stays", index); } [Theory] [InlineData("../escape")] [InlineData("has space")] [InlineData("UPPER")] [InlineData("trailing-")] [InlineData("")] public async Task SaveAsync_RejectsInvalidSlug(string slug) { var store = new FindingsStore(); await Assert.ThrowsAsync( () => store.SaveAsync(_root, Input(slug), CancellationToken.None)); } [Fact] public async Task SaveAsync_FlagsNearCapacityAtWarnThreshold() { var store = new FindingsStore(); SaveFindingOutcome outcome = null!; for (var i = 0; i < FindingsStore.WarnThreshold; i++) outcome = await store.SaveAsync(_root, Input($"trap-{i:000}"), CancellationToken.None); Assert.Equal(FindingsStore.WarnThreshold, outcome.TotalFindings); Assert.True(outcome.NearCapacity); } [Fact] public async Task SaveAsync_DoesNotFlagNearCapacityBelowThreshold() { var store = new FindingsStore(); var outcome = await store.SaveAsync(_root, Input("only-one"), CancellationToken.None); Assert.False(outcome.NearCapacity); } } ``` - [ ] **Step 2: Run the tests to verify they fail** Run: `dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj -c Release --filter FullyQualifiedName~FindingsStoreTests` Expected: FAIL — compile error, `FindingsStore` / `FindingInput` / `SaveFindingOutcome` do not exist. - [ ] **Step 3: Write the interface** `src/ClaudeDo.Worker/Findings/Interfaces/IFindingsStore.cs`: ```csharp namespace ClaudeDo.Worker.Findings; /// One durable finding, as handed in by a caller. Git and DB lookups happen above this layer. public sealed record FindingInput( string Slug, string Title, string Body, string Scope, string SourceTaskId, string VerifiedAgainst); public sealed record SaveFindingOutcome( string Slug, string Path, bool Created, int TotalFindings, bool NearCapacity); public interface IFindingsStore { Task SaveAsync(string workingDir, FindingInput input, CancellationToken ct); } ``` - [ ] **Step 4: Write the implementation** `src/ClaudeDo.Worker/Findings/FindingsStore.cs`: ```csharp using System.Text; using System.Text.RegularExpressions; namespace ClaudeDo.Worker.Findings; /// /// Owns <working-dir>/.claudedo/ — one markdown file per finding plus a rebuilt INDEX.md. /// Pure filesystem: the caller supplies the head commit and source task id. /// INDEX.md is always regenerated from the files on disk, never patched, so findings the user /// deleted or renamed by hand disappear from the index on the next write. /// public sealed class FindingsStore : IFindingsStore { /// INDEX.md is read by every run; past this many entries it stops paying for itself. public const int WarnThreshold = 80; private static readonly Regex SlugPattern = new("^[a-z0-9]+(-[a-z0-9]+)*$", RegexOptions.Compiled); private readonly SemaphoreSlim _indexLock = new(1, 1); public static string StoreDir(string workingDir) => Path.Combine(workingDir, ".claudedo"); public static string TrapsDir(string workingDir) => Path.Combine(StoreDir(workingDir), "traps"); public static string IndexPath(string workingDir) => Path.Combine(StoreDir(workingDir), "INDEX.md"); public async Task SaveAsync(string workingDir, FindingInput input, CancellationToken ct) { if (string.IsNullOrWhiteSpace(input.Slug) || input.Slug.Length > 60 || !SlugPattern.IsMatch(input.Slug)) throw new ArgumentException( $"Slug '{input.Slug}' is invalid. Use lowercase kebab-case, max 60 chars (e.g. 'conpty-arg-quoting').", nameof(input)); if (string.IsNullOrWhiteSpace(input.Title)) throw new ArgumentException("Title must not be empty.", nameof(input)); if (string.IsNullOrWhiteSpace(input.Body)) throw new ArgumentException("Body must not be empty.", nameof(input)); var trapsDir = TrapsDir(workingDir); Directory.CreateDirectory(trapsDir); var path = Path.Combine(trapsDir, input.Slug + ".md"); var created = !File.Exists(path); await File.WriteAllTextAsync(path, Render(input), ct); int total; await _indexLock.WaitAsync(ct); try { total = await RebuildIndexAsync(workingDir, ct); } finally { _indexLock.Release(); } return new SaveFindingOutcome(input.Slug, path, created, total, total >= WarnThreshold); } private static string Render(FindingInput input) { var sb = new StringBuilder(); sb.Append("---\n"); sb.Append($"slug: {input.Slug}\n"); sb.Append($"scope: {input.Scope}\n"); sb.Append($"source-task: {input.SourceTaskId}\n"); sb.Append($"verified-against: {input.VerifiedAgainst}\n"); sb.Append("---\n\n"); sb.Append($"# {input.Title.Trim()}\n\n"); sb.Append(input.Body.Trim()); sb.Append('\n'); return sb.ToString(); } private static async Task RebuildIndexAsync(string workingDir, CancellationToken ct) { var trapsDir = TrapsDir(workingDir); var slugs = Directory.Exists(trapsDir) ? Directory.EnumerateFiles(trapsDir, "*.md") .Select(Path.GetFileNameWithoutExtension) .Where(s => !string.IsNullOrEmpty(s)) .Select(s => s!) .OrderBy(s => s, StringComparer.Ordinal) .ToList() : new List(); var sb = new StringBuilder(); sb.Append("# Findings\n\n"); sb.Append("Traps and invariants for this project. Read this index first, then open only the\n"); sb.Append("findings that touch your task. Generated by ClaudeDo — edits to entries belong in\n"); sb.Append("`traps/`, this file is rewritten on every save.\n\n"); if (slugs.Count == 0) { sb.Append("_No findings yet._\n"); } else { foreach (var slug in slugs) { var title = await ReadTitleAsync(Path.Combine(trapsDir, slug + ".md"), slug, ct); sb.Append($"- [{slug}](traps/{slug}.md) — {title}\n"); } } await File.WriteAllTextAsync(IndexPath(workingDir), sb.ToString(), ct); return slugs.Count; } private static async Task ReadTitleAsync(string path, string fallback, CancellationToken ct) { foreach (var line in await File.ReadAllLinesAsync(path, ct)) { if (line.StartsWith("# ", StringComparison.Ordinal)) return line[2..].Trim(); } return fallback; } } ``` - [ ] **Step 5: Run the tests to verify they pass** Run: `dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj -c Release --filter FullyQualifiedName~FindingsStoreTests` Expected: PASS, 9 tests. - [ ] **Step 6: Commit** ```bash git add src/ClaudeDo.Worker/Findings/FindingsStore.cs src/ClaudeDo.Worker/Findings/Interfaces/IFindingsStore.cs tests/ClaudeDo.Worker.Tests/Findings/FindingsStoreTests.cs git commit -m "feat(findings): add the .claudedo findings store" ``` --- ### Task 2: Extract GitExcludeWriter and keep untracked stores out of git **Files:** - Create: `src/ClaudeDo.Worker/Git/GitExcludeWriter.cs` - Modify: `src/ClaudeDo.Worker/Skills/SessionSkillSeeder.cs:37-38,56-115` - Modify: `src/ClaudeDo.Worker/Findings/FindingsStore.cs` - Modify: `tests/ClaudeDo.Worker.Tests/Findings/FindingsStoreTests.cs` `SessionSkillSeeder` already solves "write a path into `.git/info/exclude` without touching a tracked file". Move that code out so the findings store shares it instead of duplicating it. `.git/info/exclude` — not `.gitignore` — is the point: it is per-clone and invisible to the repository. - [ ] **Step 1: Write the failing test** Create `tests/ClaudeDo.Worker.Tests/Findings/FindingsStoreGitTests.cs` — a separate class because these need a real repo. Use the project's `GitRepoFixture` (it creates a temp repo with an initial commit and force-deletes read-only `.git` objects on Windows) and follow the convention of skipping when git is unavailable: ```csharp using ClaudeDo.Worker.Findings; using ClaudeDo.Worker.Tests.Infrastructure; namespace ClaudeDo.Worker.Tests.Findings; public sealed class FindingsStoreGitTests : IDisposable { private readonly GitRepoFixture _repo = new(); public void Dispose() => _repo.Dispose(); private static FindingInput Input(string slug) => new(slug, "Something is not what it looks like", "Body.", "src", "task-1", "abc1234"); [SkippableFact] public async Task SaveAsync_WhenNotTracked_ExcludesStoreFromGitOnce() { Skip.IfNot(GitRepoFixture.IsGitAvailable()); var store = new FindingsStore(); await store.SaveAsync(_repo.RepoDir, Input("first"), CancellationToken.None, tracked: false); await store.SaveAsync(_repo.RepoDir, Input("second"), CancellationToken.None, tracked: false); var exclude = await File.ReadAllLinesAsync(Path.Combine(_repo.RepoDir, ".git", "info", "exclude")); Assert.Single(exclude, l => l.Trim() == "/.claudedo/"); } [SkippableFact] public async Task SaveAsync_WhenTracked_DoesNotTouchExclude() { Skip.IfNot(GitRepoFixture.IsGitAvailable()); var store = new FindingsStore(); await store.SaveAsync(_repo.RepoDir, Input("first"), CancellationToken.None, tracked: true); var excludePath = Path.Combine(_repo.RepoDir, ".git", "info", "exclude"); var lines = File.Exists(excludePath) ? await File.ReadAllLinesAsync(excludePath) : Array.Empty(); Assert.DoesNotContain(lines, l => l.Trim() == "/.claudedo/"); } } ``` **Before writing this:** check how the other git-dependent suites in this project express the skip — `CLAUDE.md` says "conditionally skipped via `Skip = ...`". If `Xunit.SkippableFact` is not referenced, use whatever the neighbouring git tests use (e.g. `[Fact(Skip = ...)]` guarded by a static, or an early `return`) rather than adding a new test dependency. - [ ] **Step 2: Run the tests to verify they fail** Run: `dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj -c Release --filter FullyQualifiedName~FindingsStoreTests` Expected: FAIL — compile error, `SaveAsync` has no `tracked` parameter. - [ ] **Step 3: Create the shared writer** `src/ClaudeDo.Worker/Git/GitExcludeWriter.cs` — this is the body moved verbatim out of `SessionSkillSeeder`: ```csharp using System.Diagnostics; using System.Text; namespace ClaudeDo.Worker.Git; /// /// Appends paths to a repository's .git/info/exclude. Deliberately not .gitignore: the exclude /// file is per-clone and invisible to the repository, so ClaudeDo never modifies a tracked file. /// Shared by SessionSkillSeeder (seeded skills) and FindingsStore (untracked findings stores). /// public static class GitExcludeWriter { /// Idempotent — an already-present line is left alone. public static async Task AppendLineAsync(string workingDir, string excludeLine, CancellationToken ct) { var relativeExcludePath = await RunGitCaptureAsync(workingDir, ["rev-parse", "--git-path", "info/exclude"], ct); var excludeFile = Path.IsPathRooted(relativeExcludePath) ? relativeExcludePath : Path.GetFullPath(Path.Combine(workingDir, relativeExcludePath)); var excludeDir = Path.GetDirectoryName(excludeFile); if (!string.IsNullOrEmpty(excludeDir)) Directory.CreateDirectory(excludeDir); if (File.Exists(excludeFile)) { var existingLines = await File.ReadAllLinesAsync(excludeFile, ct); if (existingLines.Any(l => l.Trim() == excludeLine)) return; } await File.AppendAllTextAsync(excludeFile, excludeLine + Environment.NewLine, ct); } private static async Task RunGitCaptureAsync(string workingDir, IEnumerable args, CancellationToken ct) { var psi = new ProcessStartInfo { FileName = "git", RedirectStandardOutput = true, RedirectStandardError = true, UseShellExecute = false, CreateNoWindow = true, StandardOutputEncoding = Encoding.UTF8, StandardErrorEncoding = Encoding.UTF8, }; psi.ArgumentList.Add("-C"); psi.ArgumentList.Add(workingDir); foreach (var a in args) psi.ArgumentList.Add(a); using var proc = new Process { StartInfo = psi }; proc.Start(); await using var ctr = ct.Register(() => { try { proc.Kill(entireProcessTree: true); } catch { /* already exited */ } }); var stdoutTask = proc.StandardOutput.ReadToEndAsync(); var stderrTask = proc.StandardError.ReadToEndAsync(); await proc.WaitForExitAsync(CancellationToken.None); var stdout = await stdoutTask; var stderr = await stderrTask; ct.ThrowIfCancellationRequested(); if (proc.ExitCode != 0) throw new InvalidOperationException($"git rev-parse --git-path failed (exit {proc.ExitCode}): {stderr.TrimEnd()}"); return stdout.TrimEnd('\r', '\n'); } } ``` - [ ] **Step 4: Point SessionSkillSeeder at it** In `src/ClaudeDo.Worker/Skills/SessionSkillSeeder.cs`, add `using ClaudeDo.Worker.Git;` at the top, replace the call on line 38: ```csharp if (isWorktree) await GitExcludeWriter.AppendLineAsync(workingDir, $"/.claude/skills/{name}/", ct); ``` Then delete the now-unused private `AppendExcludeLineAsync` and `RunGitCaptureAsync` methods (lines 56–115) and the now-unused `using System.Diagnostics;` / `using System.Text;` if nothing else in the file needs them. - [ ] **Step 5: Add the tracked flag to the store** In `src/ClaudeDo.Worker/Findings/Interfaces/IFindingsStore.cs`: ```csharp public interface IFindingsStore { Task SaveAsync(string workingDir, FindingInput input, CancellationToken ct, bool tracked = false); } ``` In `src/ClaudeDo.Worker/Findings/FindingsStore.cs`, add `using ClaudeDo.Worker.Git;`, change the signature and insert the exclude call right after `Directory.CreateDirectory(trapsDir);`: ```csharp public async Task SaveAsync( string workingDir, FindingInput input, CancellationToken ct, bool tracked = false) { // ... validation unchanged ... var trapsDir = TrapsDir(workingDir); Directory.CreateDirectory(trapsDir); if (!tracked) await GitExcludeWriter.AppendLineAsync(workingDir, "/.claudedo/", ct); // ... rest unchanged ... ``` Update the existing tests in `FindingsStoreTests` that do **not** `git init`: they must pass `tracked: true` so the exclude write is skipped. Only the two new tests exercise the untracked path. - [ ] **Step 6: Run the tests** Run: `dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj -c Release --filter "FullyQualifiedName~FindingsStoreTests|FullyQualifiedName~SessionSkill"` Expected: PASS — the new store tests plus every pre-existing `SessionSkillSeeder` test still green. - [ ] **Step 7: Commit** ```bash git add src/ClaudeDo.Worker/Git/GitExcludeWriter.cs src/ClaudeDo.Worker/Skills/SessionSkillSeeder.cs src/ClaudeDo.Worker/Findings/FindingsStore.cs src/ClaudeDo.Worker/Findings/Interfaces/IFindingsStore.cs tests/ClaudeDo.Worker.Tests/Findings/FindingsStoreTests.cs git commit -m "refactor(git): share the info/exclude writer with the findings store" ``` --- ### Task 3: Per-list "check in .claudedo" flag **Files:** - Modify: `src/ClaudeDo.Data/Models/ListEntity.cs` - Modify: `src/ClaudeDo.Data/Configuration/ListConfiguration.cs` - Create: `src/ClaudeDo.Data/Migrations/_AddFindingsTracked.cs` (generated) The flag lives on `ListEntity` next to `IsManual`, not on `ListConfigEntity`: it is a plain per-list boolean, and a list may have no config row at all. - [ ] **Step 1: Add the property** In `src/ClaudeDo.Data/Models/ListEntity.cs`, directly under `IsManual`: ```csharp // When true the project's .claudedo/ findings store is committed with the repo; when false it // is kept out of git via .git/info/exclude (see FindingsStore). public bool FindingsTracked { get; set; } ``` - [ ] **Step 2: Map the column** In `src/ClaudeDo.Data/Configuration/ListConfiguration.cs`, next to the existing `IsManual` mapping, add: ```csharp builder.Property(l => l.FindingsTracked).HasColumnName("findings_tracked"); ``` - [ ] **Step 3: Generate the migration** Run: ```bash dotnet ef migrations add AddFindingsTracked --project src/ClaudeDo.Data/ClaudeDo.Data.csproj --startup-project src/ClaudeDo.Worker/ClaudeDo.Worker.csproj ``` Verify the generated `Up` contains exactly this shape (matching `20260727114206_AddModelPresetsAndManualFlag`): ```csharp migrationBuilder.AddColumn( name: "findings_tracked", table: "lists", type: "INTEGER", nullable: false, defaultValue: false); ``` If `defaultValue: false` is missing, add it by hand — existing rows must not fail the non-null constraint. - [ ] **Step 4: Verify the schema applies** Run: `dotnet test tests/ClaudeDo.Data.Tests/ClaudeDo.Data.Tests.csproj -c Release` Expected: PASS — these run against real SQLite, so a broken migration surfaces here. - [ ] **Step 5: Commit** ```bash git add src/ClaudeDo.Data/Models/ListEntity.cs src/ClaudeDo.Data/Configuration/ListConfiguration.cs src/ClaudeDo.Data/Migrations git commit -m "feat(data): add findings_tracked to lists" ``` --- ### Task 4: FindingsStoreLocator — resolve the target store **Files:** - Create: `src/ClaudeDo.Worker/Findings/Interfaces/IFindingsStoreLocator.cs` - Create: `src/ClaudeDo.Worker/Findings/FindingsStoreLocator.cs` - Test: `tests/ClaudeDo.Worker.Tests/Findings/FindingsStoreLocatorTests.cs` Two entry paths, one rule each. Never guess when it is ambiguous — a finding written into the wrong project is worse than a refused call. - [ ] **Step 1: Write the failing tests** Use the project's `DbFixture` (`tests/ClaudeDo.Worker.Tests/Infrastructure/DbFixture.cs`) — it creates a unique temp SQLite file, applies the schema via `EnsureCreated`, and cleans up the DB plus its WAL/SHM files. Per project convention the fixture is constructed in the test class constructor and disposed via `IDisposable`. ```csharp using ClaudeDo.Data.Models; using ClaudeDo.Data.Repositories; using ClaudeDo.Worker.Findings; using ClaudeDo.Worker.Tests.Infrastructure; namespace ClaudeDo.Worker.Tests.Findings; public sealed class FindingsStoreLocatorTests : IDisposable { private readonly DbFixture _fx = new(); public void Dispose() => _fx.Dispose(); [Fact] public async Task ResolveForTask_UsesTheTasksListWorkingDir() { using var db = _fx.CreateContext(); db.Lists.Add(new ListEntity { Id = "l1", Name = "A", CreatedAt = DateTime.UtcNow, WorkingDir = @"C:\repo-a", FindingsTracked = true }); db.Tasks.Add(new TaskEntity { Id = "t1", ListId = "l1", Title = "x", CreatedAt = DateTime.UtcNow }); await db.SaveChangesAsync(); var locator = new FindingsStoreLocator(new TaskRepository(db), new ListRepository(db)); var target = await locator.ResolveForTaskAsync("t1", CancellationToken.None); Assert.Equal(@"C:\repo-a", target.WorkingDir); Assert.True(target.Tracked); } [Fact] public async Task ResolveForList_MatchesByIdOrName() { using var db = _fx.CreateContext(); db.Lists.Add(new ListEntity { Id = "l1", Name = "Alpha", CreatedAt = DateTime.UtcNow, WorkingDir = @"C:\repo-a" }); db.Lists.Add(new ListEntity { Id = "l2", Name = "Beta", CreatedAt = DateTime.UtcNow, WorkingDir = @"C:\repo-b" }); await db.SaveChangesAsync(); var locator = new FindingsStoreLocator(new TaskRepository(db), new ListRepository(db)); Assert.Equal(@"C:\repo-b", (await locator.ResolveForListAsync("l2", CancellationToken.None)).WorkingDir); Assert.Equal(@"C:\repo-a", (await locator.ResolveForListAsync("Alpha", CancellationToken.None)).WorkingDir); } [Fact] public async Task ResolveForList_WithoutArgument_UsesTheOnlyListWithAWorkingDir() { using var db = _fx.CreateContext(); db.Lists.Add(new ListEntity { Id = "l1", Name = "Alpha", CreatedAt = DateTime.UtcNow, WorkingDir = @"C:\repo-a" }); db.Lists.Add(new ListEntity { Id = "l2", Name = "NoDir", CreatedAt = DateTime.UtcNow, WorkingDir = null }); await db.SaveChangesAsync(); var locator = new FindingsStoreLocator(new TaskRepository(db), new ListRepository(db)); var target = await locator.ResolveForListAsync("", CancellationToken.None); Assert.Equal(@"C:\repo-a", target.WorkingDir); } [Fact] public async Task ResolveForList_WithoutArgument_ThrowsAndNamesCandidatesWhenAmbiguous() { using var db = _fx.CreateContext(); db.Lists.Add(new ListEntity { Id = "l1", Name = "Alpha", CreatedAt = DateTime.UtcNow, WorkingDir = @"C:\repo-a" }); db.Lists.Add(new ListEntity { Id = "l2", Name = "Beta", CreatedAt = DateTime.UtcNow, WorkingDir = @"C:\repo-b" }); await db.SaveChangesAsync(); var locator = new FindingsStoreLocator(new TaskRepository(db), new ListRepository(db)); var ex = await Assert.ThrowsAsync( () => locator.ResolveForListAsync("", CancellationToken.None)); Assert.Contains("Alpha", ex.Message); Assert.Contains("Beta", ex.Message); } [Fact] public async Task ResolveForList_ThrowsWhenTheListHasNoWorkingDir() { using var db = _fx.CreateContext(); db.Lists.Add(new ListEntity { Id = "l1", Name = "Alpha", CreatedAt = DateTime.UtcNow, WorkingDir = null }); await db.SaveChangesAsync(); var locator = new FindingsStoreLocator(new TaskRepository(db), new ListRepository(db)); await Assert.ThrowsAsync( () => locator.ResolveForListAsync("Alpha", CancellationToken.None)); } } ``` - [ ] **Step 2: Run the tests to verify they fail** Run: `dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj -c Release --filter FullyQualifiedName~FindingsStoreLocatorTests` Expected: FAIL — `FindingsStoreLocator` does not exist. - [ ] **Step 3: Write the interface** `src/ClaudeDo.Worker/Findings/Interfaces/IFindingsStoreLocator.cs`: ```csharp namespace ClaudeDo.Worker.Findings; /// The main checkout a finding belongs to, plus whether its store is committed with the repo. public sealed record FindingsTarget(string ListId, string ListName, string WorkingDir, bool Tracked); public interface IFindingsStoreLocator { Task ResolveForTaskAsync(string taskId, CancellationToken ct); Task ResolveForListAsync(string listIdOrName, CancellationToken ct); } ``` - [ ] **Step 4: Write the implementation** `src/ClaudeDo.Worker/Findings/FindingsStoreLocator.cs`: ```csharp using ClaudeDo.Data.Models; using ClaudeDo.Data.Repositories; namespace ClaudeDo.Worker.Findings; /// /// Maps an MCP call to the project whose findings store it targets. Always resolves to the list's /// WorkingDir — the main checkout — even when the caller runs inside a worktree, because concurrent /// writes into worktree copies would produce INDEX.md merge conflicts. /// public sealed class FindingsStoreLocator : IFindingsStoreLocator { private readonly TaskRepository _tasks; private readonly ListRepository _lists; public FindingsStoreLocator(TaskRepository tasks, ListRepository lists) { _tasks = tasks; _lists = lists; } public async Task ResolveForTaskAsync(string taskId, CancellationToken ct) { var task = await _tasks.GetByIdAsync(taskId, ct) ?? throw new InvalidOperationException($"Task {taskId} not found."); var list = await _lists.GetByIdAsync(task.ListId, ct) ?? throw new InvalidOperationException($"List {task.ListId} not found."); return ToTarget(list); } public async Task ResolveForListAsync(string listIdOrName, CancellationToken ct) { var all = await _lists.GetAllAsync(ct); if (!string.IsNullOrWhiteSpace(listIdOrName)) { var match = all.FirstOrDefault(l => l.Id == listIdOrName) ?? all.FirstOrDefault(l => string.Equals(l.Name, listIdOrName, StringComparison.OrdinalIgnoreCase)) ?? throw new InvalidOperationException( $"No list matches '{listIdOrName}'. Known lists: {Names(all)}."); return ToTarget(match); } var withDir = all.Where(l => !string.IsNullOrWhiteSpace(l.WorkingDir)).ToList(); return withDir.Count switch { 1 => ToTarget(withDir[0]), 0 => throw new InvalidOperationException("No list has a working directory, so there is nowhere to save a finding."), _ => throw new InvalidOperationException( $"Several lists have a working directory — pass 'list' to pick one: {Names(withDir)}."), }; } private static string Names(IEnumerable lists) => string.Join(", ", lists.Select(l => l.Name)); private static FindingsTarget ToTarget(ListEntity list) { if (string.IsNullOrWhiteSpace(list.WorkingDir)) throw new InvalidOperationException($"List '{list.Name}' has no working directory, so it has no findings store."); return new FindingsTarget(list.Id, list.Name, list.WorkingDir, list.FindingsTracked); } } ``` - [ ] **Step 5: Run the tests** Run: `dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj -c Release --filter FullyQualifiedName~FindingsStoreLocatorTests` Expected: PASS, 5 tests. - [ ] **Step 6: Commit** ```bash git add src/ClaudeDo.Worker/Findings/FindingsStoreLocator.cs src/ClaudeDo.Worker/Findings/Interfaces/IFindingsStoreLocator.cs tests/ClaudeDo.Worker.Tests/Findings/FindingsStoreLocatorTests.cs git commit -m "feat(findings): resolve the target store from task or list" ``` --- ### Task 5: save_finding on the always-on MCP server **Files:** - Create: `src/ClaudeDo.Worker/External/FindingsMcpTools.cs` - Modify: `src/ClaudeDo.Worker/Program.cs:302-330` This is the surface interactive sessions reach (the `claudedo` server is registered globally). It needs an explicit `list` because it has no caller identity. The head commit comes from `git -C rev-parse --short HEAD`; reuse `GitService` from `ClaudeDo.Data` if it exposes a rev-parse helper, otherwise add a small private helper in this class. - [ ] **Step 1: Write the tool** ```csharp using System.ComponentModel; using ClaudeDo.Worker.Findings; using ModelContextProtocol.Server; namespace ClaudeDo.Worker.External; public sealed record SaveFindingResult( bool Saved, string Slug, string List, string Path, int TotalFindings, bool NearCapacity); [McpServerToolType] public sealed class FindingsMcpTools { private readonly IFindingsStore _store; private readonly IFindingsStoreLocator _locator; public FindingsMcpTools(IFindingsStore store, IFindingsStoreLocator locator) { _store = store; _locator = locator; } [McpServerTool, Description( "Record a durable trap or invariant you just learned about this codebase, so future sessions " + "read it instead of rediscovering it. Only for findings that are lasting, non-obvious and " + "change how someone acts (\"X looks like Y but is Z — do W instead\"). A bug you fixed is not " + "a finding, that is git history. Re-using a slug overwrites that finding. " + "nearCapacity=true means the index is getting too long to stay cheap — prune before adding more.")] public async Task SaveFinding( [Description("Stable lowercase kebab-case id, max 60 chars, e.g. 'conpty-arg-quoting'. Same slug overwrites.")] string slug, [Description("One full sentence stating the trap itself — this is the index line other agents scan.")] string title, [Description("2-6 sentences: what happens, why it does not look like that, what to do instead.")] string body, [Description("Repo-relative path or subsystem the finding applies to, e.g. 'src/ClaudeDo.Worker/Planning'.")] string scope = "", [Description("List id or name. Optional when exactly one list has a working directory.")] string list = "", CancellationToken cancellationToken = default) { var target = await _locator.ResolveForListAsync(list, cancellationToken); var head = await GitHead.ShortAsync(target.WorkingDir, cancellationToken); var outcome = await _store.SaveAsync( target.WorkingDir, new FindingInput(slug, title, body, scope, SourceTaskId: "", VerifiedAgainst: head), cancellationToken, target.Tracked); return new SaveFindingResult( true, outcome.Slug, target.ListName, outcome.Path, outcome.TotalFindings, outcome.NearCapacity); } } ``` - [ ] **Step 2: Add the head-commit helper** `src/ClaudeDo.Worker/Git/GitHead.cs`: ```csharp namespace ClaudeDo.Worker.Git; /// Short HEAD of a checkout, stamped into a finding's frontmatter so staleness is visible. public static class GitHead { public static async Task ShortAsync(string workingDir, CancellationToken ct) { try { return await GitExcludeWriter.RevParseAsync(workingDir, ["rev-parse", "--short", "HEAD"], ct); } catch (InvalidOperationException) { return "unknown"; // fresh repo with no commits, or not a repo — never block a save on this } } } ``` Promote `GitExcludeWriter.RunGitCaptureAsync` to `internal static Task RevParseAsync(...)` (same body, renamed and made non-private) so both callers share the process plumbing. - [ ] **Step 3: Register in DI and with the server** In `src/ClaudeDo.Worker/Program.cs`, in the external block (around line 302), add alongside the other `AddScoped` calls: ```csharp externalBuilder.Services.AddSingleton(app.Services.GetRequiredService()); externalBuilder.Services.AddScoped(); externalBuilder.Services.AddScoped(); ``` and in the `.WithTools<…>()` chain (after line 330's `AttachmentMcpTools`): ```csharp .WithTools(); ``` In the main builder (near line 176), register the store as a singleton so both servers share the index lock: ```csharp builder.Services.AddSingleton(); ``` - [ ] **Step 4: Verify the convention tests still pass** Run: `dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj -c Release --filter FullyQualifiedName~ExternalMcpToolSchema` Expected: PASS — `NoExternalTool_HasARequiredNullableParameter` sweeps the whole `External` namespace and will now include `SaveFinding`. `scope` and `list` are non-nullable `string` with `= ""` defaults, so they are not required-and-nullable. - [ ] **Step 5: Build the worker** Run: `dotnet build src/ClaudeDo.Worker/ClaudeDo.Worker.csproj -c Release` Expected: Build succeeded, 0 errors. - [ ] **Step 6: Commit** ```bash git add src/ClaudeDo.Worker/External/FindingsMcpTools.cs src/ClaudeDo.Worker/Git/GitHead.cs src/ClaudeDo.Worker/Git/GitExcludeWriter.cs src/ClaudeDo.Worker/Program.cs git commit -m "feat(mcp): add save_finding to the external server" ``` --- ### Task 6: save_finding on the in-task MCP server **Files:** - Create: `src/ClaudeDo.Worker/Runner/TaskRunFindingsMcpTools.cs` - Modify: `src/ClaudeDo.Worker/Program.cs:176-185` Same operation, but the running task is known from the per-run token, so no `list` argument and no chance of writing into the wrong project. This is the highest-value path: the agent calls it in the turn where it just lost time. - [ ] **Step 1: Write the tool** ```csharp using System.ComponentModel; using ClaudeDo.Worker.Findings; using ClaudeDo.Worker.Git; using ModelContextProtocol.Server; namespace ClaudeDo.Worker.Runner; public sealed record SaveTaskFindingResult( bool Saved, string Slug, string Path, int TotalFindings, bool NearCapacity); [McpServerToolType] public sealed class TaskRunFindingsMcpTools { private readonly IFindingsStore _store; private readonly IFindingsStoreLocator _locator; private readonly TaskRunMcpContextAccessor _ctx; public TaskRunFindingsMcpTools( IFindingsStore store, IFindingsStoreLocator locator, TaskRunMcpContextAccessor ctx) { _store = store; _locator = locator; _ctx = ctx; } [McpServerTool, Description( "Record a durable trap or invariant you just hit, so future runs read it instead of losing the " + "same turns. Only for findings that are lasting, non-obvious and change how someone acts " + "(\"X looks like Y but is Z — do W instead\"). A bug you fixed is not a finding, that is git " + "history. Re-using a slug overwrites that finding. Writes to this task's own project.")] public async Task SaveFinding( [Description("Stable lowercase kebab-case id, max 60 chars, e.g. 'conpty-arg-quoting'. Same slug overwrites.")] string slug, [Description("One full sentence stating the trap itself — this is the index line other agents scan.")] string title, [Description("2-6 sentences: what happens, why it does not look like that, what to do instead.")] string body, [Description("Repo-relative path or subsystem the finding applies to, e.g. 'src/ClaudeDo.Worker/Planning'.")] string scope = "", CancellationToken cancellationToken = default) { var taskId = _ctx.Current.CallerTaskId; var target = await _locator.ResolveForTaskAsync(taskId, cancellationToken); var head = await GitHead.ShortAsync(target.WorkingDir, cancellationToken); var outcome = await _store.SaveAsync( target.WorkingDir, new FindingInput(slug, title, body, scope, SourceTaskId: taskId, VerifiedAgainst: head), cancellationToken, target.Tracked); return new SaveTaskFindingResult( true, outcome.Slug, outcome.Path, outcome.TotalFindings, outcome.NearCapacity); } } ``` - [ ] **Step 2: Register it** In `src/ClaudeDo.Worker/Program.cs`, after line 176 (`AddScoped()`): ```csharp builder.Services.AddScoped(); builder.Services.AddScoped(); ``` and extend the chain at line 182-185: ```csharp builder.Services.AddMcpServer() .WithHttpTransport() .WithTools() .WithTools() .WithTools(); ``` - [ ] **Step 3: Build** Run: `dotnet build src/ClaudeDo.Worker/ClaudeDo.Worker.csproj -c Release` Expected: Build succeeded, 0 errors. - [ ] **Step 4: Run the full worker suite** Run: `dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj -c Release` Expected: PASS — no regressions. Note the DI registration is duplicated across two independent service collections (main + `externalBuilder`); that is how the existing servers are wired, not an accident. - [ ] **Step 5: Commit** ```bash git add src/ClaudeDo.Worker/Runner/TaskRunFindingsMcpTools.cs src/ClaudeDo.Worker/Program.cs git commit -m "feat(mcp): add save_finding to the in-task server" ``` --- ### Task 7: Point autonomous runs at the index **Files:** - Modify: `src/ClaudeDo.Worker/Runner/TaskRunner.cs:575-576` `MergeInstructions` is `params string?[]`, so this is one extra layer. It goes **before** the list and task prompts so a user-written prompt still has the last word. Only emit the pointer when the index actually exists — an empty store would send agents to read a file that is not there. - [ ] **Step 1: Add the pointer** Replace lines 575-576 with: ```csharp var findingsPointer = BuildFindingsPointer(list?.WorkingDir); var instructions = MergeInstructions( systemFile, improvementPrompt, global.DefaultClaudeInstructions, findingsPointer, listConfig?.SystemPrompt, task.SystemPrompt); ``` If the enclosing method has no `list` in scope, load it next to `listConfig` using the same repository call already used there. - [ ] **Step 2: Add the builder** Next to `MergeInstructions` (around line 664): ```csharp /// /// Points the run at the project's findings index. Kept to one sentence on purpose: it enters the /// prefix of every message in the run, so its cost is multiplied by the turn count. /// internal static string? BuildFindingsPointer(string? workingDir) { if (string.IsNullOrWhiteSpace(workingDir)) return null; var index = Path.Combine(workingDir, ".claudedo", "INDEX.md"); if (!File.Exists(index)) return null; return $"Known traps for this project are indexed at {index}. Read that index before exploring " + "the code, and open only the findings that touch your task. When you hit a lasting, " + "non-obvious trap yourself, record it with the save_finding tool."; } ``` - [ ] **Step 3: Write the test** Append to the existing `TaskRunner` test file (grep `tests/ClaudeDo.Worker.Tests/` for `MergeInstructions` to find it): ```csharp [Fact] public void BuildFindingsPointer_ReturnsNullWhenNoIndexExists() { var dir = Path.Combine(Path.GetTempPath(), "cdo-ptr-" + Guid.NewGuid().ToString("N")); Directory.CreateDirectory(dir); try { Assert.Null(TaskRunner.BuildFindingsPointer(dir)); Assert.Null(TaskRunner.BuildFindingsPointer(null)); Assert.Null(TaskRunner.BuildFindingsPointer(" ")); } finally { Directory.Delete(dir, recursive: true); } } [Fact] public void BuildFindingsPointer_MentionsTheIndexPathWhenItExists() { var dir = Path.Combine(Path.GetTempPath(), "cdo-ptr-" + Guid.NewGuid().ToString("N")); Directory.CreateDirectory(Path.Combine(dir, ".claudedo")); File.WriteAllText(Path.Combine(dir, ".claudedo", "INDEX.md"), "# Findings\n"); try { var pointer = TaskRunner.BuildFindingsPointer(dir); Assert.NotNull(pointer); Assert.Contains("INDEX.md", pointer); Assert.Contains("save_finding", pointer); } finally { Directory.Delete(dir, recursive: true); } } ``` - [ ] **Step 4: Run the tests** Run: `dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj -c Release --filter FullyQualifiedName~TaskRunner` Expected: PASS. - [ ] **Step 5: Commit** ```bash git add src/ClaudeDo.Worker/Runner/TaskRunner.cs tests/ClaudeDo.Worker.Tests git commit -m "feat(runner): point autonomous runs at the findings index" ``` --- ### Task 8: List-settings toggle **Files:** - Modify: `src/ClaudeDo.Ui/ViewModels/Modals/ListSettingsModalViewModel.cs` - Modify: `src/ClaudeDo.Ui/Views/Modals/ListSettingsModalView.axaml:65-69` - Modify: `src/ClaudeDo.Localization/locales/en.json`, `src/ClaudeDo.Localization/locales/de.json` There is no "create list" dialog — lists are created inline and configured afterwards — so the toggle belongs here, mirroring the existing `IsManual` checkbox exactly. - [ ] **Step 1: Add the localization keys** In `en.json`, next to `modals.listSettings.manualList`: ```json "findingsTracked": "Commit the .claudedo findings folder", "findingsTrackedHint": "Off: the folder stays out of git via .git/info/exclude. On: findings travel with the repo.", ``` In `de.json`, the same keys: ```json "findingsTracked": "Findings-Ordner .claudedo einchecken", "findingsTrackedHint": "Aus: der Ordner bleibt über .git/info/exclude aus git heraus. An: Findings reisen mit dem Repo.", ``` - [ ] **Step 2: Verify key parity** Run: `dotnet test tests/ClaudeDo.Localization.Tests/ClaudeDo.Localization.Tests.csproj -c Release` Expected: PASS — this suite fails if `en.json` and `de.json` keys diverge. - [ ] **Step 3: Add the observable property** In `ListSettingsModalViewModel.cs`, next to line 32's `[ObservableProperty] private bool _isManual;`: ```csharp [ObservableProperty] private bool _findingsTracked; ``` Load it where `IsManual` is loaded from the entity, and write it back where `IsManual` is saved. Follow the exact same two call sites — grep the file for `IsManual` and mirror each occurrence. - [ ] **Step 4: Add the checkbox** In `ListSettingsModalView.axaml`, directly after the existing manual-list block (lines 65-69): ```xml ``` - [ ] **Step 5: Build and run the UI suite** Run: `dotnet build src/ClaudeDo.App/ClaudeDo.App.csproj -c Release && dotnet test tests/ClaudeDo.Ui.Tests/ClaudeDo.Ui.Tests.csproj -c Release` Expected: Build succeeded; tests PASS. If a hand-rolled `IWorkerClient` fake breaks, update it in **both** test projects. - [ ] **Step 6: Commit** ```bash git add src/ClaudeDo.Ui/ViewModels/Modals/ListSettingsModalViewModel.cs src/ClaudeDo.Ui/Views/Modals/ListSettingsModalView.axaml src/ClaudeDo.Localization/locales/en.json src/ClaudeDo.Localization/locales/de.json git commit -m "feat(ui): toggle whether the findings folder is committed" ``` **Visual verification required (cannot be asserted in tests):** open list settings and confirm the new checkbox and hint sit in the same rhythm as the manual-list pair above them. --- ### Task 9: "Open findings" on the list **Files:** - Modify: `src/ClaudeDo.Ui/ViewModels/Islands/ListsIslandViewModel.cs:107-119` - Modify: the list context menu AXAML that binds `OpenInExplorerCommand` (grep `OpenInExplorer` under `src/ClaudeDo.Ui/Views/`) - Modify: `src/ClaudeDo.Localization/locales/en.json`, `de.json` A direct variant of the existing `OpenInExplorer` command, pointed one level deeper. Silently no-ops when the store does not exist yet — a menu entry that opens an empty explorer window is worse than one that does nothing. - [ ] **Step 1: Add the localization keys** `en.json`, next to the existing open-in-explorer label: ```json "openFindings": "Open findings folder", ``` `de.json`: ```json "openFindings": "Findings-Ordner öffnen", ``` - [ ] **Step 2: Add the command** In `ListsIslandViewModel.cs`, directly after `OpenInExplorer`: ```csharp [RelayCommand] private void OpenFindings(ListNavItemViewModel? row) { var dir = row?.WorkingDir; if (string.IsNullOrWhiteSpace(dir)) return; var findings = System.IO.Path.Combine(dir, ".claudedo"); if (!System.IO.Directory.Exists(findings)) return; try { System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo { FileName = findings, UseShellExecute = true, }); } catch (Exception ex) { FlashFooterError(ex.Message); } } ``` Match the existing `OpenInExplorer` catch block exactly — if it uses a different error-surfacing call, copy that one rather than introducing `FlashFooterError` here. - [ ] **Step 3: Add the menu entry** In the AXAML that already binds `OpenInExplorerCommand`, add directly below it: ```xml ``` Match the surrounding `MenuItem` binding style — if the existing entry uses `$parent[...]` for the command source, copy that form. - [ ] **Step 4: Build and test** Run: `dotnet build src/ClaudeDo.App/ClaudeDo.App.csproj -c Release && dotnet test tests/ClaudeDo.Ui.Tests/ClaudeDo.Ui.Tests.csproj -c Release && dotnet test tests/ClaudeDo.Localization.Tests/ClaudeDo.Localization.Tests.csproj -c Release` Expected: Build succeeded; both suites PASS. - [ ] **Step 5: Commit** ```bash git add src/ClaudeDo.Ui src/ClaudeDo.Localization git commit -m "feat(ui): open a list's findings folder" ``` **Visual verification required:** right-click a list with a `.claudedo` folder and confirm the entry appears and opens the folder; right-click one without and confirm it does nothing rather than erroring. --- ## Final verification - [ ] **Build everything** ```bash dotnet build src/ClaudeDo.App/ClaudeDo.App.csproj -c Release dotnet build src/ClaudeDo.Worker/ClaudeDo.Worker.csproj -c Release ``` - [ ] **Run every suite** ```bash dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj -c Release dotnet test tests/ClaudeDo.Data.Tests/ClaudeDo.Data.Tests.csproj -c Release dotnet test tests/ClaudeDo.Ui.Tests/ClaudeDo.Ui.Tests.csproj -c Release dotnet test tests/ClaudeDo.Localization.Tests/ClaudeDo.Localization.Tests.csproj -c Release ``` - [ ] **Manual smoke test (no automated substitute exists)** 1. Start the worker, open a Claude session, call `save_finding` with slug `smoke-test`. 2. Confirm `/.claudedo/traps/smoke-test.md` exists with frontmatter, and `INDEX.md` lists it. 3. With the list toggle **off**, confirm `.git/info/exclude` contains `/.claudedo/` exactly once and `git status` is clean. 4. Flip the toggle **on**, save another finding, confirm no second exclude line was written. 5. Run an autonomous task in that list and confirm the run's system prompt mentions `INDEX.md` (check the run's NDJSON log under `~/.todo-app/logs/`). - [ ] **Update the docs** Add a `Findings/` line to the folder layout in `src/ClaudeDo.Worker/CLAUDE.md`, and note the `save_finding` tool in the External section there. Do **not** add a new explore-note — this subsystem is small enough that the CLAUDE.md entry is the right altitude. ```bash git add src/ClaudeDo.Worker/CLAUDE.md git commit -m "docs(worker): record the findings store" ```