Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,20 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [Unreleased]

### Added

- **`unwritten ignore <trigger> <hole> --for <n>`** — bounded mute for a
persistently false rule. Expires after the trigger has changed `n` more
times (default 30; those are the commits that erode or re-confirm the rule),
shows as `suppressed` with the remaining budget rather than disappearing,
is overridden by `check --strict`, and lives in machine-managed
`.unwritten/ignores.json`. `--list` / `--remove` manage entries; expired
entries are pruned automatically. Deliberately CLI-only — no MCP tool, so
agents cannot mute their own warnings. Permanent ignores do not exist by
design: unbounded mutes go stale and eventually hide real omissions.

## [0.3.0] - 2026-07-12

### Added
Expand Down
34 changes: 31 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -101,12 +101,11 @@ The pattern is clear: below 0.6 the warnings are mostly noise, above 0.6 they qu

## Use

No install step — `dotnet tool execute` (or its alias `dnx`) downloads the tool
on first use and runs it:
No install step — `dotnet tool execute` downloads the tool on first use and
runs it (the shorter alias `dnx` works too, but examples here spell it out):

```bash
dotnet tool execute Unwritten --yes -- check --staged
# dnx Unwritten --yes -- check --staged works too
```

Everything before `--` is for the tool runner; everything after it is the
Expand Down Expand Up @@ -260,6 +259,35 @@ repos:

With [Husky](https://typicode.github.io/husky/): `echo "dotnet tool execute Unwritten --yes -- check --staged" > .husky/pre-commit`.

### Muting a false rule — `unwritten ignore`

Sometimes a high-confidence rule is simply wrong for your situation and keeps
blocking commits. Content-aware suppression already absorbs cosmetic C# and
JSON edits automatically, and `git commit --no-verify` bypasses a single
commit — but for a *persistently* false pairing, mute it:

```bash
dotnet tool execute Unwritten --yes -- ignore docs/api.md src/openapi.json --for 30 --note "generated separately now"
dotnet tool execute Unwritten --yes -- ignore --list
dotnet tool execute Unwritten --yes -- ignore --remove docs/api.md src/openapi.json
```

Ignores are **bounded by design** — permanent mutes go stale and one day hide a
real omission. An ignore expires after the trigger has changed `--for` more
times (default 30): those are exactly the commits that either erode the false
rule (each trigger-alone commit lowers its confidence, so it often dies
naturally before the mute expires) or prove the coupling is real again. Muted
holes still appear as `suppressed` with their remaining budget — never silently
dropped — and `check --strict` overrides them. Ignores live in
`.unwritten/ignores.json` (machine-managed; keep your hands in `config.json`).

There is deliberately no MCP tool for creating ignores: muting a warning is a
human judgment, not something a coding agent should do to its own findings.
The loop is still closed, though — the `check_holes`/`explain_rule` tool
descriptions and the Stop-hook feedback instruct the agent that when it judges
a rule persistently false, it must say so and point you at `unwritten ignore`,
so the decision reaches you instead of dying in the agent's transcript.

### Configuration — `.unwritten/config.json`

The first index build drops a fully commented template at
Expand Down
61 changes: 61 additions & 0 deletions src/Unwritten.Storage/IgnoreStore.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
using System.Text.Json;

namespace Unwritten.Storage;

/// <summary>
/// One muted rule: the directed pair (trigger → hole) stops alerting until the
/// trigger has reached <see cref="UntilTriggerChanges"/> total changes. Bounded
/// by design — permanent ignores rot, and only commits that touch the trigger
/// can re-confirm or erode the rule, so those are what the expiry counts.
/// </summary>
public sealed record IgnoreEntry(
string Trigger,
string Hole,
int UntilTriggerChanges,
DateTimeOffset CreatedAt,
string? Note);

/// <summary>
/// Loads and saves <c>.unwritten/ignores.json</c>. Machine-managed (via
/// <c>unwritten ignore</c>) and kept separate from the hand-edited config.json
/// so the tool can rewrite it freely.
/// </summary>
public static class IgnoreStore
{
private static readonly JsonSerializerOptions JsonOptions = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
WriteIndented = true,
};

public static string GetPath(string repoPath) =>
Path.Combine(repoPath, ".unwritten", "ignores.json");

public static IReadOnlyList<IgnoreEntry> Load(string repoPath)
{
string path = GetPath(repoPath);
if (!File.Exists(path))
{
return [];
}

try
{
return JsonSerializer.Deserialize<List<IgnoreEntry>>(File.ReadAllText(path), JsonOptions) ?? [];
}
catch (JsonException)
{
// Corrupt mute file: fail toward alerting, never toward silence.
return [];
}
}

public static void Save(string repoPath, IReadOnlyList<IgnoreEntry> entries)
{
string path = GetPath(repoPath);
string directory = Path.GetDirectoryName(path)!;
Directory.CreateDirectory(directory);
IndexStore.EnsureSelfGitignore(directory);
File.WriteAllText(path, JsonSerializer.Serialize(entries, JsonOptions) + "\n");
}
}
52 changes: 49 additions & 3 deletions src/Unwritten/CheckCommand.cs
Original file line number Diff line number Diff line change
Expand Up @@ -113,17 +113,27 @@ public static int Run(string[] args, IndexManager indexManager, GitTransactionSo
var holes = RuleEngine.FindHoles(persisted.Index, entities, minConfidence);
var annotated = HoleSuppression.Annotate(persisted.Index, gitSource, repoPath, holes, staged, baseRevision);

var ignores = IgnoreStore.Load(repoPath);
annotated = IgnoreFilter.Apply(persisted.Index, annotated, ignores);

var members = indexManager.GetMembersUpToDate(repoPath);
var memberReport = MemberHoleFinder.Find(members, gitSource, repoPath, entities, staged, minConfidence, baseRevision);

var active = annotated.Where(a => !a.Suppressed || strict).ToList();
var suppressed = annotated.Where(a => a.Suppressed && !strict).ToList();
var memberHoles = memberReport?.Holes ?? [];

IReadOnlyList<HoleResult> memberHoles = memberReport?.Holes ?? [];
IReadOnlyList<AnnotatedHole> ignoredMemberHoles = [];
if (!strict && members is not null && memberHoles.Count > 0)
{
(memberHoles, ignoredMemberHoles) = IgnoreFilter.SplitMemberHoles(members.Index, memberHoles, ignores);
}

if (active.Count == 0 && memberHoles.Count == 0)
{
output.WriteLine(Inv($"No holes at confidence >= {minConfidence:0.00} for {entities.Length} file(s)."));
PrintSuppressed(output, suppressed);
PrintSuppressed(output, ignoredMemberHoles);
return 0;
}

Expand Down Expand Up @@ -166,17 +176,53 @@ public static int Run(string[] args, IndexManager indexManager, GitTransactionSo
}

PrintSuppressed(output, suppressed);
PrintSuppressed(output, ignoredMemberHoles);

bool failing = active.Any(a => a.Hole.Confidence >= failAt) ||
memberHoles.Any(h => h.Confidence >= failAt);
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}."));
PrintDecisionGuide(output, [.. failingFileHoles, .. failingMemberHoles]);
}

return failing ? 1 : 0;
}

/// <summary>
/// The exact decision, spelled out per failing hole — the user (possibly via
/// an agent relaying this verbatim) should never have to construct a command.
/// </summary>
private static void PrintDecisionGuide(TextWriter output, IReadOnlyList<HoleResult> failingHoles)
{
var holes = failingHoles.DistinctBy(h => (h.Trigger, h.Hole)).ToList();

output.WriteLine();
output.WriteLine("Your decision, per hole:");
output.WriteLine(" 1. The warning is right — you forgot this file. Update it and include it in this commit:");
foreach (var hole in holes)
{
output.WriteLine($" {hole.Hole} (usually changes together with {hole.Trigger})");
}

output.WriteLine(" 2. Not needed for THIS commit, but the rule is valid — bypass once");
output.WriteLine(" (note: skips ALL pre-commit hooks and every hole above at once):");
output.WriteLine(" git commit --no-verify");
output.WriteLine(" 3. The rule itself is no longer valid — mute it for the next 30 changes of the trigger:");
foreach (var hole in holes)
{
output.WriteLine($" dotnet tool execute Unwritten --yes -- ignore {hole.Trigger} {hole.Hole} --for 30");
}

if (holes.Count > 1)
{
output.WriteLine();
output.WriteLine(" Different decisions for different holes? Fix and/or mute those first,");
output.WriteLine(" then retry the commit — only if legitimate one-time holes remain, use --no-verify.");
}
}

/// <summary>
/// Says out loud when a checked file has no or too little history — an empty
/// result for such a file means "no data", not "no holes".
Expand Down
1 change: 1 addition & 0 deletions src/Unwritten/HookCommand.cs
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,7 @@ public static int Stop(IndexManager indexManager, GitTransactionSource gitSource
error.WriteLine("Unwritten found likely-missing companion changes in this session's edits:");
error.WriteLine(buffer.ToString());
error.WriteLine("Fix each hole or state briefly why it does not apply here (the evidence above and the explain_rule tool can help you judge).");
error.WriteLine("If you judge a rule to be a persistently false pattern, tell the user — they can mute it with 'unwritten ignore <trigger> <hole> --for <n>'. That decision is theirs, not yours.");
return 2;
}
catch (Exception)
Expand Down
Loading
Loading