Files
ClaudeDo/docs/superpowers/plans/2026-08-10-findings-store.md

53 KiB
Raw Permalink Blame History

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.cssave_finding on the always-on server (list argument).
  • src/ClaudeDo.Worker/Runner/TaskRunFindingsMcpTools.cssave_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
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:

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:

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
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:

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:

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:

            if (isWorktree)
                await GitExcludeWriter.AppendLineAsync(workingDir, $"/.claude/skills/{name}/", ct);

Then delete the now-unused private AppendExcludeLineAsync and RunGitCaptureAsync methods (lines 56115) 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:

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);:

    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
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:

    // 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:

        builder.Property(l => l.FindingsTracked).HasColumnName("findings_tracked");
  • Step 3: Generate the migration

Run:

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):

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
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.

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:

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:

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
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
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:

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:

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):

        .WithTools<FindingsMcpTools>();

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

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
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
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>()):

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

and extend the chain at line 182-185:

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
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:

        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):

    /// <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):

    [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
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:

"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:

"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;:

    [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):

        <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
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:

"openFindings": "Open findings folder",

de.json:

"openFindings": "Findings-Ordner öffnen",
  • Step 2: Add the command

In ListsIslandViewModel.cs, directly after OpenInExplorer:

    [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:

                <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
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
dotnet build src/ClaudeDo.App/ClaudeDo.App.csproj -c Release
dotnet build src/ClaudeDo.Worker/ClaudeDo.Worker.csproj -c Release
  • Run every suite
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.

git add src/ClaudeDo.Worker/CLAUDE.md
git commit -m "docs(worker): record the findings store"