blotter-cli 0.15.0

A tiny CLI for AI agents to log the cuts they hit during work.
Documentation

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

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.

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. 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 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:

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:

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.

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.

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.

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.

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.

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.

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.

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/blockerINFO/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.

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:

## 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