feat(findings): add the .claudedo findings store
This commit is contained in:
@@ -0,0 +1,116 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
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);
|
||||
}
|
||||
Reference in New Issue
Block a user