diff --git a/src/ClaudeDo.Worker/Findings/FindingsStore.cs b/src/ClaudeDo.Worker/Findings/FindingsStore.cs
new file mode 100644
index 00000000..7354777c
--- /dev/null
+++ b/src/ClaudeDo.Worker/Findings/FindingsStore.cs
@@ -0,0 +1,116 @@
+using System.Text;
+using System.Text.RegularExpressions;
+
+namespace ClaudeDo.Worker.Findings;
+
+///
+/// Owns <working-dir>/.claudedo/ — one markdown file per finding plus a rebuilt INDEX.md.
+/// Pure filesystem: the caller supplies the head commit and source task id.
+/// INDEX.md is always regenerated from the files on disk, never patched, so findings the user
+/// deleted or renamed by hand disappear from the index on the next write.
+///
+public sealed class FindingsStore : IFindingsStore
+{
+ /// INDEX.md is read by every run; past this many entries it stops paying for itself.
+ public const int WarnThreshold = 80;
+
+ private static readonly Regex SlugPattern = new("^[a-z0-9]+(-[a-z0-9]+)*$", RegexOptions.Compiled);
+ private readonly SemaphoreSlim _indexLock = new(1, 1);
+
+ public static string StoreDir(string workingDir) => Path.Combine(workingDir, ".claudedo");
+ public static string TrapsDir(string workingDir) => Path.Combine(StoreDir(workingDir), "traps");
+ public static string IndexPath(string workingDir) => Path.Combine(StoreDir(workingDir), "INDEX.md");
+
+ public async Task SaveAsync(string workingDir, FindingInput input, CancellationToken ct)
+ {
+ if (string.IsNullOrWhiteSpace(input.Slug) || input.Slug.Length > 60 || !SlugPattern.IsMatch(input.Slug))
+ throw new ArgumentException(
+ $"Slug '{input.Slug}' is invalid. Use lowercase kebab-case, max 60 chars (e.g. 'conpty-arg-quoting').",
+ nameof(input));
+ if (string.IsNullOrWhiteSpace(input.Title))
+ throw new ArgumentException("Title must not be empty.", nameof(input));
+ if (string.IsNullOrWhiteSpace(input.Body))
+ throw new ArgumentException("Body must not be empty.", nameof(input));
+
+ var trapsDir = TrapsDir(workingDir);
+ Directory.CreateDirectory(trapsDir);
+
+ var path = Path.Combine(trapsDir, input.Slug + ".md");
+ var created = !File.Exists(path);
+
+ await File.WriteAllTextAsync(path, Render(input), ct);
+
+ int total;
+ await _indexLock.WaitAsync(ct);
+ try
+ {
+ total = await RebuildIndexAsync(workingDir, ct);
+ }
+ finally
+ {
+ _indexLock.Release();
+ }
+
+ return new SaveFindingOutcome(input.Slug, path, created, total, total >= WarnThreshold);
+ }
+
+ private static string Render(FindingInput input)
+ {
+ var sb = new StringBuilder();
+ sb.Append("---\n");
+ sb.Append($"slug: {input.Slug}\n");
+ sb.Append($"scope: {input.Scope}\n");
+ sb.Append($"source-task: {input.SourceTaskId}\n");
+ sb.Append($"verified-against: {input.VerifiedAgainst}\n");
+ sb.Append("---\n\n");
+ sb.Append($"# {input.Title.Trim()}\n\n");
+ sb.Append(input.Body.Trim());
+ sb.Append('\n');
+ return sb.ToString();
+ }
+
+ private static async Task RebuildIndexAsync(string workingDir, CancellationToken ct)
+ {
+ var trapsDir = TrapsDir(workingDir);
+ var slugs = Directory.Exists(trapsDir)
+ ? Directory.EnumerateFiles(trapsDir, "*.md")
+ .Select(Path.GetFileNameWithoutExtension)
+ .Where(s => !string.IsNullOrEmpty(s))
+ .Select(s => s!)
+ .OrderBy(s => s, StringComparer.Ordinal)
+ .ToList()
+ : new List();
+
+ var sb = new StringBuilder();
+ sb.Append("# Findings\n\n");
+ sb.Append("Traps and invariants for this project. Read this index first, then open only the\n");
+ sb.Append("findings that touch your task. Generated by ClaudeDo — edits to entries belong in\n");
+ sb.Append("`traps/`, this file is rewritten on every save.\n\n");
+
+ if (slugs.Count == 0)
+ {
+ sb.Append("_No findings yet._\n");
+ }
+ else
+ {
+ foreach (var slug in slugs)
+ {
+ var title = await ReadTitleAsync(Path.Combine(trapsDir, slug + ".md"), slug, ct);
+ sb.Append($"- [{slug}](traps/{slug}.md) — {title}\n");
+ }
+ }
+
+ await File.WriteAllTextAsync(IndexPath(workingDir), sb.ToString(), ct);
+ return slugs.Count;
+ }
+
+ private static async Task ReadTitleAsync(string path, string fallback, CancellationToken ct)
+ {
+ foreach (var line in await File.ReadAllLinesAsync(path, ct))
+ {
+ if (line.StartsWith("# ", StringComparison.Ordinal))
+ return line[2..].Trim();
+ }
+ return fallback;
+ }
+}
diff --git a/src/ClaudeDo.Worker/Findings/Interfaces/IFindingsStore.cs b/src/ClaudeDo.Worker/Findings/Interfaces/IFindingsStore.cs
new file mode 100644
index 00000000..eb015b52
--- /dev/null
+++ b/src/ClaudeDo.Worker/Findings/Interfaces/IFindingsStore.cs
@@ -0,0 +1,22 @@
+namespace ClaudeDo.Worker.Findings;
+
+/// One durable finding, as handed in by a caller. Git and DB lookups happen above this layer.
+public sealed record FindingInput(
+ string Slug,
+ string Title,
+ string Body,
+ string Scope,
+ string SourceTaskId,
+ string VerifiedAgainst);
+
+public sealed record SaveFindingOutcome(
+ string Slug,
+ string Path,
+ bool Created,
+ int TotalFindings,
+ bool NearCapacity);
+
+public interface IFindingsStore
+{
+ Task SaveAsync(string workingDir, FindingInput input, CancellationToken ct);
+}
diff --git a/tests/ClaudeDo.Worker.Tests/Findings/FindingsStoreTests.cs b/tests/ClaudeDo.Worker.Tests/Findings/FindingsStoreTests.cs
new file mode 100644
index 00000000..843aa492
--- /dev/null
+++ b/tests/ClaudeDo.Worker.Tests/Findings/FindingsStoreTests.cs
@@ -0,0 +1,127 @@
+using ClaudeDo.Worker.Findings;
+
+namespace ClaudeDo.Worker.Tests.Findings;
+
+public sealed class FindingsStoreTests : IDisposable
+{
+ private readonly string _root;
+
+ public FindingsStoreTests()
+ {
+ _root = Path.Combine(Path.GetTempPath(), "cdo-findings-" + Guid.NewGuid().ToString("N"));
+ Directory.CreateDirectory(_root);
+ }
+
+ public void Dispose()
+ {
+ try { Directory.Delete(_root, recursive: true); } catch { /* best effort */ }
+ }
+
+ private static FindingInput Input(string slug, string title = "Something is not what it looks like")
+ => new(slug, title, "Body line one.\n\nBody line two.", "src/ClaudeDo.Worker", "task-1", "abc1234");
+
+ [Fact]
+ public async Task SaveAsync_WritesFindingFileWithFrontmatter()
+ {
+ var store = new FindingsStore();
+
+ var outcome = await store.SaveAsync(_root, Input("conpty-arg-quoting"), CancellationToken.None);
+
+ Assert.True(outcome.Created);
+ Assert.Equal("conpty-arg-quoting", outcome.Slug);
+
+ var text = await File.ReadAllTextAsync(Path.Combine(_root, ".claudedo", "traps", "conpty-arg-quoting.md"));
+ Assert.StartsWith("---\n", text.Replace("\r\n", "\n"));
+ Assert.Contains("slug: conpty-arg-quoting", text);
+ Assert.Contains("scope: src/ClaudeDo.Worker", text);
+ Assert.Contains("source-task: task-1", text);
+ Assert.Contains("verified-against: abc1234", text);
+ Assert.Contains("# Something is not what it looks like", text);
+ Assert.Contains("Body line one.", text);
+ }
+
+ [Fact]
+ public async Task SaveAsync_SameSlugOverwritesInsteadOfCreatingASecondFile()
+ {
+ var store = new FindingsStore();
+ await store.SaveAsync(_root, Input("dup", "First title"), CancellationToken.None);
+
+ var outcome = await store.SaveAsync(_root, Input("dup", "Second title"), CancellationToken.None);
+
+ Assert.False(outcome.Created);
+ Assert.Equal(1, outcome.TotalFindings);
+ var files = Directory.GetFiles(Path.Combine(_root, ".claudedo", "traps"));
+ Assert.Single(files);
+ var text = await File.ReadAllTextAsync(files[0]);
+ Assert.Contains("# Second title", text);
+ Assert.DoesNotContain("# First title", text);
+ }
+
+ [Fact]
+ public async Task SaveAsync_RebuildsIndexFromDiskSortedBySlug()
+ {
+ var store = new FindingsStore();
+ await store.SaveAsync(_root, Input("zulu", "Zulu trap"), CancellationToken.None);
+ await store.SaveAsync(_root, Input("alpha", "Alpha trap"), CancellationToken.None);
+
+ var index = await File.ReadAllTextAsync(Path.Combine(_root, ".claudedo", "INDEX.md"));
+ var lines = index.Replace("\r\n", "\n").Split('\n');
+
+ var alphaAt = Array.FindIndex(lines, l => l.Contains("alpha"));
+ var zuluAt = Array.FindIndex(lines, l => l.Contains("zulu"));
+ Assert.True(alphaAt >= 0 && zuluAt >= 0);
+ Assert.True(alphaAt < zuluAt);
+ Assert.Contains("- [alpha](traps/alpha.md) — Alpha trap", index);
+ }
+
+ [Fact]
+ public async Task SaveAsync_IndexDropsFindingsDeletedOnDisk()
+ {
+ var store = new FindingsStore();
+ await store.SaveAsync(_root, Input("gone", "Gone trap"), CancellationToken.None);
+ await store.SaveAsync(_root, Input("stays", "Stays trap"), CancellationToken.None);
+
+ File.Delete(Path.Combine(_root, ".claudedo", "traps", "gone.md"));
+ await store.SaveAsync(_root, Input("stays", "Stays trap"), CancellationToken.None);
+
+ var index = await File.ReadAllTextAsync(Path.Combine(_root, ".claudedo", "INDEX.md"));
+ Assert.DoesNotContain("gone", index);
+ Assert.Contains("stays", index);
+ }
+
+ [Theory]
+ [InlineData("../escape")]
+ [InlineData("has space")]
+ [InlineData("UPPER")]
+ [InlineData("trailing-")]
+ [InlineData("")]
+ public async Task SaveAsync_RejectsInvalidSlug(string slug)
+ {
+ var store = new FindingsStore();
+
+ await Assert.ThrowsAsync(
+ () => store.SaveAsync(_root, Input(slug), CancellationToken.None));
+ }
+
+ [Fact]
+ public async Task SaveAsync_FlagsNearCapacityAtWarnThreshold()
+ {
+ var store = new FindingsStore();
+ SaveFindingOutcome outcome = null!;
+ for (var i = 0; i < FindingsStore.WarnThreshold; i++)
+ outcome = await store.SaveAsync(_root, Input($"trap-{i:000}"), CancellationToken.None);
+
+ Assert.Equal(FindingsStore.WarnThreshold, outcome.TotalFindings);
+ Assert.True(outcome.NearCapacity);
+ }
+
+ [Fact]
+ public async Task SaveAsync_DoesNotFlagNearCapacityBelowThreshold()
+ {
+ var store = new FindingsStore();
+
+ var outcome = await store.SaveAsync(_root, Input("only-one"), CancellationToken.None);
+
+ Assert.False(outcome.NearCapacity);
+ }
+}