# 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 `<working-dir>/.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/<slug>.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/<timestamp>_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<ArgumentException>(
            () => 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;

/// <summary>One durable finding, as handed in by a caller. Git and DB lookups happen above this layer.</summary>
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<SaveFindingOutcome> 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;

/// <summary>
/// Owns &lt;working-dir&gt;/.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.
/// </summary>
public sealed class FindingsStore : IFindingsStore
{
    /// <summary>INDEX.md is read by every run; past this many entries it stops paying for itself.</summary>
    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<SaveFindingOutcome> 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<int> 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<string>();

        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<string> 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<string>();
        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;

/// <summary>
/// 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).
/// </summary>
public static class GitExcludeWriter
{
    /// <summary>Idempotent — an already-present line is left alone.</summary>
    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<string> RunGitCaptureAsync(string workingDir, IEnumerable<string> 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<SaveFindingOutcome> 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<SaveFindingOutcome> 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/<timestamp>_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<bool>(
    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<InvalidOperationException>(
            () => 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<InvalidOperationException>(
            () => 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;

/// <summary>The main checkout a finding belongs to, plus whether its store is committed with the repo.</summary>
public sealed record FindingsTarget(string ListId, string ListName, string WorkingDir, bool Tracked);

public interface IFindingsStoreLocator
{
    Task<FindingsTarget> ResolveForTaskAsync(string taskId, CancellationToken ct);
    Task<FindingsTarget> 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;

/// <summary>
/// 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.
/// </summary>
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<FindingsTarget> 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<FindingsTarget> 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<ListEntity> 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 <workingDir> 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<SaveFindingResult> 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;

/// <summary>Short HEAD of a checkout, stamped into a finding's frontmatter so staleness is visible.</summary>
public static class GitHead
{
    public static async Task<string> 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<string> 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<IFindingsStore>(app.Services.GetRequiredService<IFindingsStore>());
externalBuilder.Services.AddScoped<IFindingsStoreLocator, FindingsStoreLocator>();
externalBuilder.Services.AddScoped<FindingsMcpTools>();
```

and in the `.WithTools<…>()` chain (after line 330's `AttachmentMcpTools`):

```csharp
        .WithTools<FindingsMcpTools>();
```

In the main builder (near line 176), register the store as a singleton so both servers share the index lock:

```csharp
builder.Services.AddSingleton<IFindingsStore, FindingsStore>();
```

- [ ] **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<SaveTaskFindingResult> 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<TaskRunMcpService>()`):

```csharp
builder.Services.AddScoped<IFindingsStoreLocator, FindingsStoreLocator>();
builder.Services.AddScoped<TaskRunFindingsMcpTools>();
```

and extend the chain at line 182-185:

```csharp
builder.Services.AddMcpServer()
    .WithHttpTransport()
    .WithTools<PlanningMcpService>()
    .WithTools<TaskRunMcpService>()
    .WithTools<TaskRunFindingsMcpTools>();
```

- [ ] **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
    /// <summary>
    /// 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.
    /// </summary>
    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
        <CheckBox IsChecked="{Binding FindingsTracked, Mode=TwoWay}"
                  Content="{loc:Tr modals.listSettings.findingsTracked}"/>
        <TextBlock Text="{loc:Tr modals.listSettings.findingsTrackedHint}"
                   Opacity="0.6" FontSize="12" TextWrapping="Wrap"/>
```

- [ ] **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
                <MenuItem Header="{loc:Tr lists.openFindings}"
                          Command="{Binding OpenFindingsCommand}"
                          CommandParameter="{Binding}"/>
```

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 `<working-dir>/.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"
```
