diff --git a/src/Unwritten.Storage/FindingsLog.cs b/src/Unwritten.Storage/FindingsLog.cs
new file mode 100644
index 0000000..c788ccb
--- /dev/null
+++ b/src/Unwritten.Storage/FindingsLog.cs
@@ -0,0 +1,108 @@
+using System.Text.Json;
+using System.Text.Json.Serialization;
+
+namespace Unwritten.Storage;
+
+///
+/// Append-only log of every findings result the tool hands to a consumer
+/// (MCP model, CLI, Stop hook), written to .unwritten/findings.log as a
+/// stream of pretty-printed JSON entries. Best-effort: logging must never fail
+/// a check, so all I/O errors are swallowed. Size-capped: once the file grows
+/// past the oldest entries are trimmed away.
+///
+public static class FindingsLog
+{
+ /// Cap on the log file size; oldest entries are trimmed once exceeded.
+ private const long MaxBytes = 1024 * 1024;
+
+ /// Trim down to this size (newest entries kept) so trims stay infrequent.
+ private const long TrimTargetBytes = MaxBytes / 2;
+
+ private static readonly JsonSerializerOptions JsonOptions = new()
+ {
+ PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
+ WriteIndented = true,
+ DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
+ };
+
+ public static string GetLogPath(string repoPath) =>
+ Path.Combine(repoPath, ".unwritten", "findings.log");
+
+ ///
+ /// Appends one entry recording the findings returned to a consumer.
+ /// names the surface ("mcp", "cli", "stop-hook").
+ ///
+ public static void Append(string repoPath, string source, object result)
+ {
+ try
+ {
+ string directory = Path.Combine(repoPath, ".unwritten");
+ Directory.CreateDirectory(directory);
+ IndexStore.EnsureSelfGitignore(directory);
+
+ var entry = new { Timestamp = DateTimeOffset.UtcNow, Source = source, Result = result };
+ string path = GetLogPath(repoPath);
+ File.AppendAllText(path, JsonSerializer.Serialize(entry, JsonOptions) + Environment.NewLine);
+ TrimIfOverCap(path);
+ }
+ catch
+ {
+ // Best-effort by design: a broken log must never fail a check, and on
+ // the MCP path stdout is protocol so there is nowhere safe to report.
+ }
+ }
+
+ private static void TrimIfOverCap(string path)
+ {
+ if (new FileInfo(path).Length <= MaxBytes)
+ {
+ return;
+ }
+
+ var lines = File.ReadAllLines(path);
+
+ // Entries are pretty-printed, so each spans many lines; an entry starts
+ // where its opening brace sits alone at column 0.
+ var starts = new List();
+ for (int i = 0; i < lines.Length; i++)
+ {
+ if (lines[i] == "{")
+ {
+ starts.Add(i);
+ }
+ }
+
+ if (starts.Count <= 1)
+ {
+ return;
+ }
+
+ // Walk entries newest-first, keeping whole entries (always at least the
+ // newest) until the trim target is reached. Char count approximates
+ // bytes well enough for a cap on mostly-ASCII JSON.
+ long kept = 0;
+ int firstKept = -1;
+ for (int s = starts.Count - 1; s >= 0; s--)
+ {
+ int end = s + 1 < starts.Count ? starts[s + 1] : lines.Length;
+ long entrySize = 0;
+ for (int i = starts[s]; i < end; i++)
+ {
+ entrySize += lines[i].Length + Environment.NewLine.Length;
+ }
+
+ if (firstKept >= 0 && kept + entrySize > TrimTargetBytes)
+ {
+ break;
+ }
+
+ kept += entrySize;
+ firstKept = starts[s];
+ }
+
+ // Unique temp name then move: an MCP server and a hook can log concurrently.
+ string tempPath = Path.Combine(Path.GetDirectoryName(path)!, Path.GetRandomFileName() + ".tmp");
+ File.WriteAllLines(tempPath, lines.Skip(firstKept));
+ File.Move(tempPath, path, overwrite: true);
+ }
+}
diff --git a/src/Unwritten.Storage/IndexStore.cs b/src/Unwritten.Storage/IndexStore.cs
index 756f25e..a690f67 100644
--- a/src/Unwritten.Storage/IndexStore.cs
+++ b/src/Unwritten.Storage/IndexStore.cs
@@ -12,6 +12,9 @@ public static class IndexStore
private static readonly JsonSerializerOptions JsonOptions = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
+ // Deliberately compact: the index is machine state with no human reader,
+ // it is deserialized fresh on every CLI/hook invocation, and on large
+ // repos it reaches tens of MB — indentation would add ~60% to that cost.
WriteIndented = false,
};
diff --git a/src/Unwritten/CheckCommand.cs b/src/Unwritten/CheckCommand.cs
index 68807b3..a16a65e 100644
--- a/src/Unwritten/CheckCommand.cs
+++ b/src/Unwritten/CheckCommand.cs
@@ -28,7 +28,7 @@ public static class CheckCommand
--strict Treat content-suppressed holes as real holes.
""";
- public static int Run(string[] args, IndexManager indexManager, GitTransactionSource gitSource, TextWriter output)
+ public static int Run(string[] args, IndexManager indexManager, GitTransactionSource gitSource, TextWriter output, string logSource = "cli")
{
bool staged = false;
bool strict = false;
@@ -129,6 +129,13 @@ public static int Run(string[] args, IndexManager indexManager, GitTransactionSo
(memberHoles, ignoredMemberHoles) = IgnoreFilter.SplitMemberHoles(members.Index, memberHoles, ignores);
}
+ var failingFileHoles = active.Where(a => a.Hole.Confidence >= failAt).Select(a => a.Hole).ToList();
+ var failingMemberHoles = memberHoles.Where(h => h.Confidence >= failAt).ToList();
+ bool failing = failingFileHoles.Count > 0 || failingMemberHoles.Count > 0;
+
+ LogFindings(repoPath, logSource, entities, minConfidence, failAt, active, memberHoles, members,
+ suppressed, ignoredMemberHoles, failing);
+
if (active.Count == 0 && memberHoles.Count == 0)
{
output.WriteLine(Inv($"No holes at confidence >= {minConfidence:0.00} for {entities.Length} file(s)."));
@@ -178,9 +185,6 @@ public static int Run(string[] args, IndexManager indexManager, GitTransactionSo
PrintSuppressed(output, suppressed);
PrintSuppressed(output, ignoredMemberHoles);
- var failingFileHoles = active.Where(a => a.Hole.Confidence >= failAt).Select(a => a.Hole).ToList();
- var failingMemberHoles = memberHoles.Where(h => h.Confidence >= failAt).ToList();
- bool failing = failingFileHoles.Count > 0 || failingMemberHoles.Count > 0;
if (failing)
{
output.WriteLine(Inv($"FAIL: at least one hole at confidence >= {failAt:0.00}."));
@@ -256,5 +260,46 @@ private static void PrintSuppressed(TextWriter output, IReadOnlyList
+ /// Records what this check reported to its consumer in .unwritten/findings.log
+ /// (same structured shape as the MCP tool logs, so the log reads uniformly).
+ ///
+ private static void LogFindings(
+ string repoPath,
+ string source,
+ IReadOnlyList entities,
+ double minConfidence,
+ double failAt,
+ IReadOnlyList active,
+ IReadOnlyList memberHoles,
+ PersistedIndex? members,
+ IReadOnlyList suppressed,
+ IReadOnlyList ignoredMemberHoles,
+ bool failing)
+ {
+ object HoleDto(HoleResult h, string? holeLocation = null) => new
+ {
+ h.Hole,
+ HoleLocation = holeLocation,
+ h.Trigger,
+ h.Confidence,
+ h.CoChanges,
+ h.TotalChanges,
+ };
+
+ FindingsLog.Append(repoPath, source, new
+ {
+ CheckedFiles = entities,
+ MinConfidence = minConfidence,
+ FailAt = failAt,
+ Holes = active.Select(a => HoleDto(a.Hole)).ToArray(),
+ MemberHoles = memberHoles.Select(h => HoleDto(h, members!.Index.GetEntityLocation(h.Hole))).ToArray(),
+ Suppressed = suppressed.Concat(ignoredMemberHoles)
+ .Select(a => new { a.Hole.Hole, a.Hole.Trigger, a.Reason })
+ .ToArray(),
+ Failing = failing,
+ });
+ }
+
private static string Inv(FormattableString message) => FormattableString.Invariant(message);
}
diff --git a/src/Unwritten/HookCommand.cs b/src/Unwritten/HookCommand.cs
index e2b2917..2dc7da8 100644
--- a/src/Unwritten/HookCommand.cs
+++ b/src/Unwritten/HookCommand.cs
@@ -93,7 +93,7 @@ public static int Stop(IndexManager indexManager, GitTransactionSource gitSource
string repoPath = indexManager.ResolveRepoRoot(
string.IsNullOrWhiteSpace(cwd) ? Directory.GetCurrentDirectory() : cwd);
using var buffer = new StringWriter();
- int exitCode = CheckCommand.Run(["--repo", repoPath], indexManager, gitSource, buffer);
+ int exitCode = CheckCommand.Run(["--repo", repoPath], indexManager, gitSource, buffer, logSource: "stop-hook");
if (exitCode != 1)
{
return 0;
diff --git a/src/Unwritten/UnwrittenTools.cs b/src/Unwritten/UnwrittenTools.cs
index 4a7cb91..3cee32e 100644
--- a/src/Unwritten/UnwrittenTools.cs
+++ b/src/Unwritten/UnwrittenTools.cs
@@ -110,19 +110,22 @@ baseRef is null
suppressReason,
};
- return Serialize(new
+ var result = new
{
- holes = annotated.Select(ToHoleDto),
+ holes = annotated.Select(ToHoleDto).ToArray(),
memberHoles = memberReport is null
? null
: memberHoles.Select(h => MemberDto(h, null))
- .Concat(ignoredMemberHoles.Select(a => MemberDto(a.Hole, a.Reason))),
+ .Concat(ignoredMemberHoles.Select(a => MemberDto(a.Hole, a.Reason)))
+ .ToArray(),
changedMembers = memberReport?.ChangedMembers,
checkedFiles,
minConfidence = floor,
baseRef,
notes = notes.Count > 0 ? notes : null,
- });
+ };
+ FindingsLog.Append(repoPath, "mcp", result);
+ return Serialize(result);
}
[McpServerTool(Name = "reindex")]
diff --git a/tests/Unwritten.Integration.Tests/FindingsLogTests.cs b/tests/Unwritten.Integration.Tests/FindingsLogTests.cs
new file mode 100644
index 0000000..d03dafa
--- /dev/null
+++ b/tests/Unwritten.Integration.Tests/FindingsLogTests.cs
@@ -0,0 +1,98 @@
+using System.Text.Json;
+using Unwritten.Storage;
+
+namespace Unwritten.Integration.Tests;
+
+public class FindingsLogTests : IDisposable
+{
+ private readonly string _repo = Path.Combine(
+ Path.GetTempPath(), "unwritten-findingslog", Path.GetRandomFileName());
+
+ public void Dispose()
+ {
+ try
+ {
+ Directory.Delete(_repo, recursive: true);
+ }
+ catch (DirectoryNotFoundException)
+ {
+ }
+ }
+
+ /// Splits the log into its pretty-printed entries (each starts with "{" at column 0).
+ private static List ReadEntries(string repoPath)
+ {
+ var entries = new List();
+ foreach (string line in File.ReadLines(FindingsLog.GetLogPath(repoPath)))
+ {
+ if (line == "{")
+ {
+ entries.Add(line);
+ }
+ else
+ {
+ entries[^1] += Environment.NewLine + line;
+ }
+ }
+
+ return entries;
+ }
+
+ [Fact]
+ public void AppendsOnePrettyPrintedEntryPerCall()
+ {
+ FindingsLog.Append(_repo, "cli", new { Holes = new[] { "docs/CHANGELOG.md" }, Failing = true });
+ FindingsLog.Append(_repo, "mcp", new { Holes = Array.Empty(), Failing = false });
+
+ var entries = ReadEntries(_repo);
+
+ Assert.Equal(2, entries.Count);
+ using var first = JsonDocument.Parse(entries[0]);
+ Assert.Equal("cli", first.RootElement.GetProperty("source").GetString());
+ Assert.Equal("docs/CHANGELOG.md",
+ first.RootElement.GetProperty("result").GetProperty("holes")[0].GetString());
+ Assert.True(first.RootElement.TryGetProperty("timestamp", out _));
+ using var second = JsonDocument.Parse(entries[1]);
+ Assert.Equal("mcp", second.RootElement.GetProperty("source").GetString());
+ }
+
+ [Fact]
+ public void SelfGitignoresTheLogDirectory()
+ {
+ FindingsLog.Append(_repo, "cli", new { Failing = false });
+
+ Assert.Equal("*\n", File.ReadAllText(Path.Combine(_repo, ".unwritten", ".gitignore")));
+ }
+
+ [Fact]
+ public void TrimsOldestEntriesOnceTheCapIsExceeded()
+ {
+ // Each entry is ~1 KB, so ~1100 of them overshoot the 1 MB cap.
+ string padding = new('x', 1000);
+ for (int i = 0; i < 1100; i++)
+ {
+ FindingsLog.Append(_repo, "cli", new { Sequence = i, Padding = padding });
+ }
+
+ var entries = ReadEntries(_repo);
+
+ Assert.True(new FileInfo(FindingsLog.GetLogPath(_repo)).Length <= 1024 * 1024);
+ Assert.NotEmpty(entries);
+
+ // The newest entry survives; the oldest was trimmed away.
+ using var last = JsonDocument.Parse(entries[^1]);
+ Assert.Equal(1099, last.RootElement.GetProperty("result").GetProperty("sequence").GetInt32());
+ using var first = JsonDocument.Parse(entries[0]);
+ Assert.True(first.RootElement.GetProperty("result").GetProperty("sequence").GetInt32() > 0);
+ }
+
+ [Fact]
+ public void SwallowsIoFailuresInsteadOfThrowing()
+ {
+ // Make the .unwritten path unusable by creating it as a FILE.
+ Directory.CreateDirectory(_repo);
+ File.WriteAllText(Path.Combine(_repo, ".unwritten"), "not a directory");
+
+ FindingsLog.Append(_repo, "cli", new { Failing = false });
+ }
+}