# blotter
A tiny CLI that gives AI agents a complaint box.
Agents hit friction constantly — dead-end tool calls, broken links, missing helpers, footgun configs — and silently push through without telling anyone. The signal evaporates. `blotter` gives an agent a one-line way to file the complaint at the moment it happens, and gives you (or another agent) a way to review the backlog and fix the actual problems in your repo, your tooling, your docs.
```
$ blotter add "yarn web:test with a root-relative path finds no files; the workspace test cwd is apps/web" --tag tooling
{"ok":true,"data":{"changed":true,"record":{"kind":"cut","id":"bl_9f2c41d0a8b3","ts":"2026-07-09T21:14:03.412Z","agent":"claude-code","text":"yarn web:test with a root-relative path finds no files; the workspace test cwd is apps/web","tags":["tooling"],"severity":"minor",...}},"meta":{"contract":5,"file":"/repo/.blotter.jsonl","agent_source":"detected"}}
```
The idea comes from [a tool Steve Ruiz built](https://x.com/steveruizok) for his own repos: once agents had a place to complain, they immediately surfaced real workflow defects — quoting bugs, wrong test working directories, YAML footguns — that they'd been eating silently for months.
This project began as a fork of [treygoff24/papercuts](https://github.com/treygoff24/papercuts) and owes its core design — the append-only journal, the agent-first envelope contract, the concurrency model — to that upstream project. The fork added dogears (an idea log alongside the friction log), structured resolve provenance, chronic-cut triage, and Claude Code hook integration, then took the name **blotter** to stand on its own. `cargo install papercuts` still installs the upstream crate, which has none of those additions.
## Install
```bash
cargo install --git https://github.com/BigCactusLabs/blotter blotter-cli
```
The crate is named `blotter-cli` (the crates.io name `blotter` is squatted by a placeholder), but the installed binary is plain `blotter`. Packaged installers are tracked as `TASK-2`.
## How it works
Cuts live in an **append-only JSONL file** — by default `.blotter.jsonl` at your repo root, so every complaint shows up in `git diff` and travels with the repo. No server, no sync, no telemetry. The file is the product.
```bash
blotter add "text" # file a cut (also: blotter log, or pipe stdin to add -)
blotter list # open cuts, severity-first then newest, JSON envelope
blotter list --format md # human review digest
blotter triage # identify chronic clusters of similar open cuts
blotter verify # check resolved cuts for later recurrences
blotter retrospect # mine chronic signal for typed promotion candidates
blotter digest --since 7d # periodic friction report: chronic, new, open ideas
blotter sweep ~/code/a ~/code/b # read-only roll-up across several repositories
blotter export --format otlp-json # one OTLP LogsData JSON line for a collector
blotter resolve bl_9f2c # mark one fixed (unique ID prefix ok)
blotter resolve bl_9f2c bl_a81e # resolve several atomically
blotter resolve <id> --pr <url> # attach structured graduation provenance
blotter resolve <id> --amend --note "..." # correct a resolution you got wrong
blotter add "tool failed" --cmd 'tool --flag' --exit 1 --stderr-file /tmp/stderr
blotter add "bad response" --evidence 'request_id=abc123'
blotter schema # full machine contract — agents self-orient with this
blotter doctor # validate the log file
blotter doctor --leaks # scan raw log lines for public home-path leaks
blotter doctor --leaks --deny "literal" # add a literal CI/push gate
blotter doctor --fix --dry-run # plan bounded repairs without writing
blotter doctor --fix # quarantine unreadable lines (backup + atomic swap)
blotter archive --before 180d --dry-run # plan a retention trim without writing
blotter archive --before 180d # move fully closed, fully old history to a sidecar
blotter hook install claude-code # auto-file cuts from failed Bash tool calls
```
The five read commands above — `list`, `triage`, `verify`, `digest`, and `sweep` — show hand-filed records by default; pass `--include-auto` to include records tagged `auto`. On `list`, `--tag auto` implies `--include-auto`, so you can ask for auto records by name without the extra flag.
Two global flags apply to every subcommand: `--file PATH` overrides log discovery for one invocation (same target as `BLOTTER_FILE`), and `--pretty` indents the JSON envelope for human reading. The one exception is `sweep`, which rejects `--file` because its inputs are its arguments.
- **Agent-first contract**: stdout is data only; one JSON envelope per command; structured errors on stderr with stable codes, documented exit codes, and a paste-ready `suggested_fix`. `blotter schema` returns the whole contract.
- **Concurrency-safe**: multiple agents on one file are fine (advisory locking, atomic appends, self-healing torn lines).
- **Deterministic**: content-addressed IDs — a cut's identity covers its timestamp, agent, text, severity, and sorted tags, so the same text filed under different tags is a different cut — plus stable sort and a reproducible-clock override for tests.
- **Never rewrites history**: `resolve` appends an event; the log is a journal, not a database. There are two exceptions, both of which write a repaired or trimmed copy and swap it in rather than editing in place. `archive` retires fully closed, fully old history to a sidecar file — see [Archive](#archive). `doctor --fix` repairs unreadable lines (`torn_line`, `malformed`, `conflict_marker`) by writing a repaired copy and atomically swapping it in — the original is kept as a timestamped backup and every removed line is preserved verbatim in `<log>.quarantine.jsonl`. Everything else doctor reports stays diagnose-only; see [Doctor](#doctor) for what to do about the findings `--fix` will not touch.
- **Evidence is bounded and redacted**: `add` can attach a failed command (`--cmd`), exit status (`--exit`), UTF-8 stderr file (`--stderr-file`), or free-form note (`--evidence`). `--stderr-file` rejects non-regular files and inputs over 1 MiB before sanitized stderr is stored up to 4096 UTF-8 bytes; a symlink is followed to its target, which must itself be a regular file. Redaction is best-effort hygiene, not a security boundary; never feed raw environment dumps.
A resolution you got wrong is corrected, not rewritten: `resolve <id> --amend` appends a second resolve event carrying the corrected fields. The first non-amend resolve stays the base event, the latest amend wins the materialized view (`resolution.amended: true`), and every original byte stays in the log. `--amend` needs at least one resolution field and every named record must already be resolved.
An amend **replaces** the materialized resolution; it does not merge field by field. If the base resolve carried `--pr` and you amend with only `--note`, the materialized `resolution` keeps the note and drops the pull request. Repeat every field you still want:
```bash
blotter resolve <id> --amend --note "corrected" --pr <url>
```
The base resolve is still in the log, as always. It is the materialized view that `list` and `verify` read — the latest amend alone — that loses the field.
`resolve` always returns a `data.records` array, including when only one ID is resolved. New records omit `repo`; their `cwd` is relative to the discovered repository root when possible, `~`-relative when outside that repository but under `$HOME`, or absolute otherwise.
New records carry `bl_`-prefixed IDs. Legacy `pc_` records remain readable as opaque historical data: existing logs fold and list normally, and `resolve` accepts explicit `pc_` IDs or prefixes. New records never use the prefix.
## Hooks
Install the Claude Code failure hook in the current repository's `.claude/settings.json`:
```bash
blotter hook install claude-code
```
Use `--settings PATH` for an explicit settings file, or `--global` for `~/.claude/settings.json`; `--dry-run` reports the exact command and path without writing. The installer preserves all unrelated JSON content and atomically replaces the settings file using a temporary file and rename; it is idempotent. If the executable has moved since the hook was installed (a rebuild, a rename, a new install path), re-running `hook install` detects the stale path and atomically repairs it, reporting `changed:true`.
Claude Code then invokes `blotter hook exec claude-code` after a failed Bash tool call. The hook files a minor cut whose text and `evidence.cmd` are the same best-effort-redacted failed command (home-path rewrite followed by the secret pass), with tags `auto` and `claude-code` and `source:"hook"` — the one provenance value `add` cannot forge, marking the record as machine-observed rather than self-reported; its human-readable failure message becomes a best-effort-redacted evidence note. It never creates a blotter log, ignores interrupts and malformed or inapplicable payloads, and keeps stdout empty with exit 0 so a logging failure cannot disrupt the host session. Three noise guards apply. It skips an event when an **open** cut already has exactly the same redacted text — once that cut is resolved, the command can be filed again. It skips a raw command longer than 500 bytes before redaction: a sprawling debugging one-liner is log noise rather than a description of friction. And it skips read-only probe commands (`grep`, `rg`, `ls`, `find`, `tail`, `head`, `cat`, `stat`, `test`, `[`, `which`, `curl`, `gh`) whose non-zero exit is an expected answer rather than friction — matched best-effort on the first program word only, after leading `VAR=value` assignments and ignoring pipelines and chains.
Auto-captured cuts are hidden from `list`, `triage`, `digest`, `verify`, and `sweep` by default. The hook captures that a command failed, not why it mattered, so those records are evidence rather than analysis; pass `--include-auto` when that evidence is needed.
Silence makes the hook hard to debug, so set `BLOTTER_HOOK_EXPLAIN=1` to have `hook exec` write one line to stderr naming why it skipped — the failed gate, the duplicate cut, an unusable clock — or the id of the cut it filed. stdout stays empty and the exit code stays 0 either way. Any other value keeps the hook silent.
## Dogears
Dogears are append-only idea-log entries for a surprising measurement, a gap in prior art, or a reusable pattern worth turning into research or writing. They are deliberately separate from friction: the default list remains cut-only.
```bash
blotter dogear "No public benchmark compares routing quality after barrier-aware pruning" --tag research --evidence "Corpus sweep, 2026-07-23"
blotter idea - --tag blog-post # pipe a dogear from stdin
blotter list --kind dogear # dogear backlog, newest first
blotter list --kind all --format md
blotter resolve bl_9f2c # promoted to writing work, or dropped
blotter resolve <id> --url <url> # dogear published at a URL
blotter resolve <id> --dropped # dogear intentionally dropped
```
Dogears use the same append-only journal, agent resolution, tags, dry-run, deterministic clock override, and resolve events as cuts. `resolve --task`, `--pr`, and `--commit` work for either kind. `--url` and `--dropped` are dogear-only, conflict with each other, and reject a mixed cut/dogear batch before anything is appended. Dogears have no severity or failure-command fields; `list --severity` is therefore accepted only with the default `--kind cut`.
## Triage
`triage` is a read-only scan of open cuts. Cuts whose normalized titles are identical always link, regardless of tags. Otherwise, cuts must share a tag (or both be untagged), then link from filtered tokens: 80% overlap with the shorter token set, or at least three shared tokens that appear in no more than `max(2, ceil(scanned / 4))` open cuts. Filtering removes tokens of two characters or fewer and a small function-word list. Only clusters that meet the threshold are reported; resolved cuts and dogears are excluded. Each cluster carries `occurrences` — how many open cuts share the normalized title of the cluster's displayed `text`. The JSON output suggests `graduate` for each chronic cluster, and exit 1 means at least one was found. Records tagged `auto` are excluded by default; pass `--include-auto` to include them.
```bash
blotter triage --min-count 3
```
`--min-count` defaults to 3 and must be at least 2; a threshold of 1 is `invalid_argument`.
## Verify
`verify` is a read-only check for cuts that reappear after they were resolved. Each eligible resolved cut is an anchor. A later open cut recurs when it matches under the same exact-title, tag, and filtered-token linkage rules as `triage`. Dogears, dropped resolutions, and blank normalized resolved titles are ignored. One open cut can be reported against more than one resolved anchor. Records tagged `auto` are excluded by default; pass `--include-auto` to include them.
```bash
blotter verify
```
The JSON envelope includes each anchor's resolution timestamp and optional task, pull request, and commit provenance, plus the later recurrence IDs. Exit 1 means one or more recurrences were found; no recurrences is exit 0.
## Retrospect
`retrospect` is a read-only mining pass over one log. It asks a different question than `triage`: not "what keeps hurting" but "what has hurt often enough to be worth building something for". It reuses triage's clustering and verify's recurrence rules unchanged, then types the result by evidence shape. A chronic cluster becomes a `wrapper_alias` candidate when half or more of its members share one failing leading program, or a `doc_repair` candidate when half or more are tagged `docs` or `documentation`; the wrapper type wins when both match. Every recurrence group of two or more members becomes a `skill_candidate`, because a cut that was resolved and came back is a recovery worth capturing. A cluster that matches no rule emits nothing and stays an ordinary cut.
```bash
blotter retrospect
```
Retrospect takes no window and no flags: chronic signal is long-horizon, so a window would hide the evidence it looks for. It also **includes auto-captured records by default**, inverting the rule the other read commands follow — the repeated-command-failure signal behind `wrapper_alias` lives in the auto lane, so excluding it would remove the point of the command.
Each candidate carries its record IDs, first and last timestamps, and bounded evidence: at most 10 member texts and 5 resolution notes, never a record's evidence command, stderr, or note. `occurrences` counts each distinct normalized title in the candidate once, so members that share a title do not multiply the count. Exit 1 means candidates were found, exit 0 means none.
Retrospect never writes anything — no doc, no skill, no alias, and no record in the log. It packages the argument for a promotion; a human decides whether to make it.
## Digest
`digest` is the periodic read-only report: what keeps recurring, what is new, and what ideas are waiting. It combines three views — chronic clusters (the triage analysis at a threshold of 2), open cuts filed inside the window grouped by tag, and all open dogears. Records tagged `auto` are excluded by default; pass `--include-auto` to include them.
```bash
blotter digest --since 7d # JSON envelope, default window
blotter digest --since 30d --format md # raw markdown, pasteable into a review
```
`--since` takes a full RFC3339 timestamp or an `Nd`/`Nh` duration. Output is byte-deterministic for a given log and clock. An empty report is exit 0, not an error.
## Sweep
`sweep` rolls several repositories' logs into one read-only view — the answer to "what is annoying my agents everywhere", not just in the repo you are standing in.
```bash
blotter sweep ~/code/api ~/code/web
blotter sweep --registry ~/.config/blotter-repos.txt --since 14d --kind all
```
Each path is a repository directory or a direct JSONL log. A **repository directory** means a directory inside a git working tree: sweep walks up to the nearest `.git` and reads `<repo root>/.blotter.jsonl`. A directory that holds a `.blotter.jsonl` but is not under git is skipped with `not a repository directory` — point sweep at the log file itself in that case. A registry is a plain text file you own with one path per line; blank lines and `#` comments are ignored, and relative paths resolve from the registry file's own directory. `blotter` never creates or looks for a registry on its own — there is no blotter-owned config file.
Sweep reads one log at a time under a shared lock and never writes. `BLOTTER_FILE` is ignored and the global `--file` flag is rejected, because sweep's inputs are its arguments. A path that is locked, unreadable, or not a repository directory becomes a skip warning and does not fail the run: sweep exits 0 with `totals.repos_skipped` set, deliberately unlike the exit-75 lock-timeout rule elsewhere. Check `totals.repos_swept` against the number of paths you passed — an all-skipped run still exits 0. Records tagged `auto` are excluded by default; pass `--include-auto` to include them.
## Export
`export` is a read-only bridge from folded cuts to OpenTelemetry. It writes one OTLP 1.11.0 `LogsData` JSON object as a single line on stdout — a raw-output exception alongside `--format md`, not the usual envelope, so `--pretty` does not apply. Pipe it to a collector or write it to a file the OTel file exporter reads.
```bash
blotter export --format otlp-json
blotter export --format otlp-json --since 30d > friction.otlp.json
```
`--format otlp-json` is required: a bare `export` is `invalid_argument`, reported before the clock is read. `--since` takes a full RFC3339 timestamp or an `Nd`/`Nh` duration. Records tagged `auto` are excluded by default; pass `--include-auto` to include them. Only cuts are exported — dogears are out of scope.
Cuts of every status are exported, and the status travels as the `blotter.friction.status` attribute (`open`, `resolved`, or `dropped`) rather than as a selector: there is no flag to export one status. Each cut becomes a log record with `eventName` `blotter.friction.reported`, a decimal-string `timeUnixNano`, the cut text as the body, severity mapped to OTLP (`minor`/`major`/`blocker` → `INFO`/`WARN`/`ERROR`), and `blotter.friction.*` attributes for id, severity, status, agent, tags, and `cwd`; a resolved cut also carries `blotter.friction.resolved_ts`.
Evidence fields are never exported. A failed command, its stderr, and free-form evidence notes are the parts of a cut most likely to hold local paths or secrets, so the outward mapping leaves them in the log. Trace and span identity is absent for the same reason — the bridge reports friction, it does not join your traces — and so is `schemaUrl`.
Output is deterministic: records sort by timestamp, then by id, and an empty selection is a stable empty record list at exit 0. OTLP types `timeUnixNano` as an unsigned 64-bit value, so a selected record whose timestamp falls outside that range (pre-1970, or past the ceiling) rejects the **whole** export with `invalid_input` (exit 65), naming the offending record and timestamp. There is no partial output and no silently skipped record; correct that record, or exclude it with `--since`, then export again.
## Doctor
`doctor` inspects every physical line of one log and reports what it finds. Exit 1 means findings, exit 0 means healthy. `--fix` repairs only the three unreadable-line kinds; everything else is diagnose-only and needs a human decision:
| Finding | `--fix` | What to do |
|---|---|---|
| `torn_line`, `malformed`, `conflict_marker` | yes | Run `blotter doctor --fix`. Removed lines are quarantined verbatim. |
| `id_conflict` | no | A record's ID does not recompute from its payload, usually because it was written before an ID-format change. Leave it — see below. |
| `duplicate_cut`, `duplicate_dogear` | no | First-wins fold warnings. Harmless — compaction is not worth a rewrite. |
| `orphan_resolve` | no | A resolve event with no matching record, often from merge ordering. Harmless to the fold. |
| `unknown_kind` | no | A record kind this build does not know. Left alone for forward compatibility. |
| `gitignored` | no | Fix `.gitignore`, not the log. |
`doctor --leaks` adds a public-log gate without changing normal doctor output. It scans the raw bytes of every physical line, including malformed lines, for current or generic Unix home paths and reports a diagnose-only `leak` finding. Use it before a push or in CI; add repeatable `--deny LITERAL` values for other literal substrings your repository must not publish. `--deny` requires `--leaks`; both conflict with `--fix`, so the gate stays read-only.
An unhealthy report is therefore not always something to repair. This repository's own log carries two permanent `id_conflict` findings from records filed before the `TASK-4` cut-ID change.
`id_conflict` in particular has **no correction workflow, by design**. There is no event that rewrites a record's ID or payload: the fold keeps the first record it sees for an ID, so appending a line with the same ID is silently ignored, and `resolve --amend` only replaces resolution fields. The record is not broken — it still folds, lists, and resolves by its stored ID. The finding is a note that the ID predates the current hash, not a defect to repair, and it will keep appearing in every `doctor` run. Changing those bytes means editing the log outside `blotter`, which breaks the append-only invariant; back the file up first and treat it as a deliberate exception, not routine maintenance. If a record's *content* is wrong, the append-only answer is to file a corrected cut and resolve the old one — that supersedes the content but leaves the `id_conflict` finding in place.
## Archive
`archive` is the retention command: it retires history that is finished and old, and leaves everything else alone. A record group is removed only when **both** conditions hold — its materialized state is resolved or dropped, and every event in the group (the record and its resolves) is older than `--before`. An open cut, or a closed cut whose resolve landed after the cutoff, stays. So do orphan resolves, malformed lines, unknown record kinds, and legacy `pc_` records: only `bl_` groups are eligible.
```bash
blotter archive --before 180d --dry-run # plan only, writes nothing
blotter archive --before 180d # apply
blotter archive --before 2026-01-01T00:00:00Z
```
`--before` is required and takes the same value grammar as `--since`: a full RFC3339 timestamp or an `Nd`/`Nh` duration. The cutoff is exclusive.
Nothing is destroyed. Applying writes two files next to the log first — a timestamped backup of the original, and `<log>.archive-<ts>.jsonl` holding every removed physical line verbatim, newline-terminated, in original order — and only then atomically swaps the kept lines into place. The envelope reports `archived` and `kept` line counts, the `backup` and `archive_file` paths, and a paste-ready `restore_hint` (a `cp` that puts the original back). If either sidecar write or the swap fails, the files it created are removed and the log is untouched. When nothing is eligible, no file is written: `changed:false`, exit 0.
If the log is a symlink, the swap follows it and lands on the real target, so the link survives.
## Give your agents the pen
Paste this into your `CLAUDE.md` / `AGENTS.md` / system prompt:
```markdown
## Blotter
Run `blotter list` first to see what is already known. Do not add global,
system, or internal friction.
When you hit friction during work — a dead-end tool call, a broken link, a
misleading doc, a footgun config, a missing helper — file it before moving on:
blotter add "<what you hit and what would have prevented it>" --tag <area>
Don't stop working; file it and push through. Severity: blocker if you could
not proceed, major if you lost real time, minor (default) for a papercut. Run
`blotter schema` once if you need the full contract. Attach `--cmd`, `--exit`,
or `--stderr-file` when filing tool failures; never feed raw environment dumps.
```
Then periodically: `blotter list --format md` and fix what your agents keep tripping over.
## Team modes
**Committed (default).** `.blotter.jsonl` is a normal tracked file — cuts appear in diffs and PRs. Add this to `.gitattributes` so parallel branches merge cleanly:
```
.blotter.jsonl merge=union
```
Duplicate lines after a merge are harmless — the fold is first-wins and `blotter add` is duplicate-safe.
**Private.** Prefer not to commit them? `echo .blotter.jsonl >> .gitignore`, or point `BLOTTER_FILE` somewhere else entirely. Outside a git repo, cuts go to `~/.blotter/log.jsonl`.
**Historical papercuts migration.** Earlier releases instructed users to run `mv .papercuts.jsonl .blotter.jsonl` (and update `.gitignore`/`.gitattributes`); a rename preserves every byte. Current releases neither discover `.papercuts.jsonl` nor emit migration warnings. Existing records remain readable after that cutoff.
## Contract
Everything an agent needs is in `blotter schema`: commands and flags with read-only/appends annotations, env vars (`BLOTTER_FILE`, `BLOTTER_AGENT`, `BLOTTER_NOW`, `BLOTTER_HOOK_EXPLAIN`), record shapes, error codes, and the exit-code dictionary (0 success · 1 command findings · 2 usage · 65 bad input · 66 not found · 70 internal · 74 I/O · 75 lock timeout, retryable · 77 permission denied · 78 config). Empty results are exit 0, never errors.
Exit 1 is not an error — it is a finding count. `doctor` returns it for an unhealthy log, `triage` for at least one chronic cluster, and `verify` for at least one recurrence. Each command's own `exit_codes` entry in `blotter schema` says which meaning applies.
## License
MIT