From 37c3b476cfb0e1ea467c627d1fa4bb5ef87fa530 Mon Sep 17 00:00:00 2001 From: mika kuns Date: Mon, 10 Aug 2026 09:51:55 +0200 Subject: [PATCH] docs(findings): plan the findings store implementation --- .../plans/2026-08-10-findings-store.md | 1327 +++++++++++++++++ 1 file changed, 1327 insertions(+) create mode 100644 docs/superpowers/plans/2026-08-10-findings-store.md diff --git a/docs/superpowers/plans/2026-08-10-findings-store.md b/docs/superpowers/plans/2026-08-10-findings-store.md new file mode 100644 index 00000000..4808d654 --- /dev/null +++ b/docs/superpowers/plans/2026-08-10-findings-store.md @@ -0,0 +1,1327 @@ +# 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" +```