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 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 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
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.
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 schemareturns 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:
resolveappends 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.archiveretires fully closed, fully old history to a sidecar file — see Archive.doctor --fixrepairs 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 for what to do about the findings--fixwill not touch. - Evidence is bounded and redacted:
addcan attach a failed command (--cmd), exit status (--exit), UTF-8 stderr file (--stderr-file), or free-form note (--evidence).--stderr-filerejects 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:
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:
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.
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.
--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.
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.
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.
--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.
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.
--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.
--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:
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:
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