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