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

1328 lines
53 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 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`:
```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"
```