Skip to main content

tackbox

tackbox logo

publish verify-release pypi

Every failure must report, propagate, or explain itself.

tackbox is a guardrail for developers and coding agents. It catches common local ways failures are hidden by accident, haste, or expediency and requires an explicit outcome: propagate, report, or explain. It is not a whole-program proof or a security boundary.

tackbox's lint contract recognizes direct reporting helpers as an explicit report outcome. Execution policy, control flow, result ownership, and runtime integration remain application decisions.

Coding agents write error handling that looks right and silently isn't: a swallowed exception, a fatal exit with nothing logged, a report with the cause stripped out. tackbox catches it the moment it's written: hooked into the agent's edit loop it flags the finding before the turn ends, and the same rules gate pre-commit and CI - one coverage bar for hand-written and agent-written code.

There are no per-rule disable flags. A local exception stays visible as a reasoned // no-report: <reason> marker at the site, and every marker must be covered by a line in the committed approval manifest (.tackbox/approvals). Adding that line is the act that draws the approval ask in an agent session; an uncovered marker keeps lint, dev.py check, and CI red until it is approved or reverted.

resp, err := client.Do(req)
if err != nil {
    return nil // looks handled; the failure just vanished
}
client.go:42: ERC001: err-branch must propagate, capture, or carry
the error into a terminal exit (err=err)

One command brings the whole stack across Go, Python, Java, JS, TS, Svelte, and Markdown - no go install, no npm i, no external opengrep:

uvx tackbox@latest lint .

The wheel is hermetic: a consumer needs only git on PATH (plus a Go toolchain if the repo has .go files, and a Java 17+ runtime if it has .java files) and, the first time a given engine version runs, network access to fetch the engine payload once. Rules roll out via @latest - a new safety rule reaches every repo on its next run.

What it catches

  • Swallowed errors - the catch {} or if err != nil { return nil } that makes a failure vanish. Every path must report, propagate, or carry an explicit // no-report: <reason>.
  • Silent exits - os.Exit, log.Fatal, System.exit, or a local die reached with an unreported error, so the process dies and your error tracker never hears about it.
  • Double reports - capturing an error and re-throwing it, so the same failure hits Sentry/glitchtip twice and drowns the signal.
  • Broken cause chains - a new exception thrown from a catch that drops the original (only its message survives), erasing the stack you'd actually debug from.
  • Silently killed tests - the it.skip with no explanation, the failing test reborn as test.todo, the it.only that quietly turns off the rest of the suite. Every skip must state a reason; focused tests are always an error.

Wiring into a repo

Call tackbox lint from the repo's dev.py lint, next to the project's own linters:

def lint():
    sh("uvx tackbox@latest lint .")
    sh("uv run ruff check .")   # project-owned, if Python

Pre-commit runs a single language-agnostic hook; dev.py check (= lint + test) decides what to scan:

# .pre-commit-config.yaml in the consumer repo
repos:
  - repo: local
    hooks:
      - id: dev-check
        name: dev.py check
        entry: python3
        args: [dev.py, check]
        language: system
        pass_filenames: false
        always_run: true

CodeClimate report

tackbox lint --codequality <path> also writes a CodeClimate-format JSON array of every finding to <path> (console output and exit code unchanged; the report is written even when findings exist). Wire it into GitLab CI as a codequality report so the MR widget renders the findings:

lint:
  script: uvx tackbox@latest lint . --codequality gl-code-quality.json
  artifacts:
    reports:
      codequality: gl-code-quality.json

Lint scope and flags

tackbox lint [path] [flags] scans the git-tracked source set. The positional path (default .) narrows the scan to a subtree; a path matching no file in the source set is a usage error (exit 2).

  • --changed limits the scan to the dirty tree: files staged, unstaged, or untracked.
  • --since <ref> limits it to the three-dot diff <ref>...HEAD (what this branch changed since its merge-base with <ref>) unioned with the dirty tree, so it already covers --changed; passing both is the same scope as --since alone. An unknown ref, or a repo with no commits yet, is a usage error (exit 2), not a crash.
  • --no-cache ignores the per-(unit, engine) result cache for this run and writes nothing back to it.

The path scope and the change filters compose: tackbox lint src --changed lints only the dirty files under src/.

This scope filter is unrelated to the escapes command's --since <rev>, which selects inventory entries new against a revision.

The approvals consistency check (see Approval manifest) is exempt from all scope filters: it always covers the whole tree and reports under an approvals (whole tree): header, scoped runs included - a scoped CI lint cannot scope the wall away.

Exit codes

Across commands, 2 is a usage or setup error the command cannot run past (argparse misuse, and the per-command cases below).

  • lint - 0 clean, 1 one or more findings, 2 a scope matching no files or a git/engine setup failure (a bad --changed / --since ref; an engine-store, reporters, or go list error). A closed downstream pipe (lint | head) exits 141; --codequality never changes the code.
  • doctor - 0 all checks pass, 1 at least one failed; every check always runs (no short-circuit).
  • approvals - 0 consistent, 2 inconsistent (uncovered markers, orphaned entries, or unresolvable files), 1 infra. --draft is a generator, not a gate: 0 when every uncovered marker was drafted (an orphan-only tree included), 2 only when unresolvable files leave the draft incomplete.
  • hook - 0 a no-op, a clean event, or a JSON decision (a PreToolUse approval prompt or a PostToolUse Bash block); 1 a non-blocking infra error (unreadable stdin, a git failure); 2 a PostToolUse finding on the edited lines, a non-compiling Go package, or an approvals inconsistency anywhere in the worktree, which blocks the edit in-loop.
  • hook-protocol - 0 whenever a decision was reached, whatever the decision says (it rides the JSON on stdout, never the exit code); 1 plus one stderr line when none was: unreadable stdin, or a request whose protocol version this tackbox does not speak.
  • escapes - 0 whenever it runs, entries or not (an inventory, not a gate); 1 only for a bad --since rev.

Distribution

uvx tackbox@latest installs one small wheel; the engine payload is fetched separately and cached per version:

  • tackbox (thin) - the Python CLI (including the pyrules flake8 plugin), the erclint / erclint-opengrep binaries, the javalint.jar, the opengrep rule yamls, and the ESLint and markdownlint plugins and presets. Platform-specific, bumped on every push.
  • tackbox-engines (fat, ~350 MB unpacked) - the bundled Node runtime, the opengrep binary, and the vendored third-party node_modules. Published as a PyPI wheel but not a pip dependency of thin. On the first run for a given engine version, tackbox resolves the wheel via the PyPI JSON API, verifies its unpacked payload against the tree sha256 pinned in the thin wheel's engines.json, and unpacks it once into $XDG_DATA_HOME/tackbox/engines/<version>/ (default ~/.local/share/...; override TACKBOX_ENGINES_DIR). A machine-wide per-version lock makes concurrent first runs share one download: one process fetches and verifies while every other process waits and then reuses the committed store. Every later thin version reuses that one copy, so a stream of @latest patch bumps never re-materializes the engines. Installed engine versions coexist rather than deleting one another during normal commands, so an older thin client cannot force a re-download. Bumped only when an engine changes.

After the first fetch tackbox runs fully offline until the engine version changes. Platform wheels cover Linux x86_64/arm64 (manylinux), macOS arm64, and Windows x86_64. engines.json in the thin wheel records the source, version, sha256, and license of every bundled binary and dependency; tackbox doctor fetches the store if absent and verifies the payload against it.

What the rules enforce

Covers ERC001-009 (Go, via erclint), JV001-010 (Java, via the native javalint engine; JV008 is retired), Python exception, notify, and test-skip rules (via the pyrules flake8 plugin), frontend swallow, notify, and test-skip rules (JS, TS, Svelte, via ESLint), and Markdown (MD001-060 + ASCII).

See go/README.md for the complete Go ruleset. Across supported languages, the core policy is:

  • Every err != nil branch must propagate, capture, or carry an explicit // no-report: <reason> marker.
  • Common parser results that fall through to nil must capture or carry // parse-skip: <reason>.
  • Terminal exits (log.Fatal*, os.Exit, project-local die) must be preceded by a capture call or carry a // no-report: <reason> marker (e.g. for the normal os.Exit(0) at the end of main).
  • Bare return nil from a single-result function must carry // nil-return: <reason> or use (val, ok) / (val, err).
  • A single err-branch may not both capture and return err.
  • The dedupKey must be a well-formed literal; in Go, capture-call arguments must additionally not carry raw user input (a *http.Request field).
  • A notify (user lane only, no capture) may terminate a failure path only when it is narrowed: a narrow catch type (Java/Python) or an additional condition inside the branch (Go/JS). An unconditional notify in a broad catch routes every failure to a toast and blinds telemetry - a finding. A single path may not both capture and notify (error/warn already reach the user, so the pair double-shows). A notify is validated like a capture: static-literal msg, well-formed literal dedupKey.
  • A skipped test must state a reason: t.Skip("why") / t.Skipf, or // test-skip: <reason> above a bare t.SkipNow(). The same contract holds in every language (skip / todo / xfail / @Disabled); focused tests (it.only, fit) are an unconditional error.

The same model is enforced beyond Go:

  • Java (javalint, JV001-010) on a typed javaparser AST: JV001 swallow (every catch path must propagate, report, print, or carry // no-report), JV002 chain (a thrown exception must carry the caught as its cause), JV003 throwable (a catch of Throwable / Error must rethrow), JV004 useless-catch (a catch that only rethrows the caught unchanged - deleted, not annotated), JV005 exit (System.exit in a catch needs a preceding capture; port of ERC003), JV006 double-capture (no path may both report and rethrow; port of ERC005 - and no path may both capture and notify), JV007 skip (@Disabled / @Ignore must carry a non-empty reason string), JV009 notify gate (a notify in a broad catch must narrow the type), and JV010 reporter args (a Report user-lane verb needs a static-literal msg and a well-formed literal dedupKey). JV008 is retired.
  • Python exception and test-skip rules ship as the pyrules flake8 plugin (TBX codes). A skip reason is accepted in any of the natural forms: @pytest.mark.skip(reason=...), @pytest.mark.skipif(cond, reason=...), @pytest.mark.xfail(reason=...), pytest.skip(...), or @unittest.skip(...). contextlib.suppress is flagged as a cosmetic dodge of the swallow rule; the one allowlisted use is asyncio.CancelledError around await task after task.cancel(), where the CancelledError on the await IS the confirmation that the cancel propagated, not an error to log. The notify gate (TBX010) and the user-lane argument contract - static-literal msg, well-formed dedup_key (TBX011) - apply to the tackbox_report verbs recognized by import origin (D010).
  • JS / TS / Svelte swallow and test-skip rules run under ESLint. A skip reason is accepted in the call itself: node:test options ({ skip: 'reason' } / { todo: 'reason' }) and Playwright's test.skip(cond, 'reason') / test.fixme(cond, 'reason'). The notify gate is no-broad-notify (a notify must sit under a condition inside the catch); valid-error-report and valid-dedup-key also validate notify's msg and dedupKey.

Python rules (TBX001-011)

The pyrules flake8 plugin emits these codes; each maps to a stable rule id (parity with the pre-migration ids).

Code Rule Summary
TBX001 swallowed-exception propagate or wrap via raise ... from e
TBX002 suppress-exception restructure so it can't raise
TBX003 bare-except catch a specific type, not bare
TBX004 reraise-without-cause keep the cause via raise ... from e
TBX005 useless-except drop a try/except that only re-raises
TBX006 import-inside-function move the import to module top
TBX007 exit-in-except don't sys.exit in except; propagate
TBX008 test-skip a skipped/xfailed test needs a reason
TBX010 notify-lane notify needs a narrow except type
TBX011 reporter-args literal msg and dedup key; data in cause/tags

Full ids carry the python- prefix (e.g. python-swallowed-exception). TBX009 is retired (the removed secret-name heuristic, D001), as JV008 is.

Duplication (DUP001, DUP002, DUP003)

The tackbox-jscpd engine wraps a copy/paste detector and runs by default over Go, Python, Java, and the JS family (.js, .jsx, .mjs, .cjs, .ts, .tsx, .svelte); Markdown is excluded, since prose repetition is not a defect. A consumer on @latest gets it in CI with no wiring.

  • DUP001 flags a duplicated block - a clone of at least 50 tokens. Both ends are reported, each a finding at its own site, naming the counterpart block and the token count.
  • DUP002 flags a native jscpd:ignore marker. That channel would bypass the gated suppression below, so its presence alone is a finding; remove it.
  • DUP003 flags a dup-ok marker that became unnecessary because its callable-header clone is filtered automatically. Remove the marker and its matching approval together.

A pair repeated wholly inside two callable headers is filtered automatically when both complete clone endpoints fit reliable syntax boundaries. The header includes the callable syntax, parameters, return/result clauses, and required separator or body opener, but no body token. This is marker-free and silent in normal, machine, CodeClimate, and hook output. If either endpoint crosses into a body, has incomplete coordinates, or has no reliable callable boundary, the pair remains an ordinary DUP001. Header expressions are not classified by purity: defaults, parameter or return annotations, destructuring, and computed names are part of the header boundary even when they can execute code.

Suppress one clone with a standalone // dup-ok: <reason> comment directly above the block - a # or a single-line /* ... */ comment works per language. The reason must be at least 10 characters (D009), and a trailing comment after code does not count. dup-ok above one end drops only that end; above both ends it drops the whole clone. Use it only for justified clones that survive automatic header filtering. A matching approval confirms that a marker is authorized; it does not make a redundant marker valid, so DUP003 remains until both source marker and approval are removed.

Duplication is cross-file, so the engine is never cached: it runs on every lint and writes no clean-cache markers. A java-format clone that lies entirely within both files' headers (package, imports, leading comments) has no extractable code and is dropped before it is reported.

Markdown: declared charset

The Markdown engine does not enforce Markdown or prose style. It runs the four link-reference built-ins - MD011 (reversed links), MD042 (empty links), MD051 (in-file link fragments), MD052 (reference links defined) - plus MD-CHARS, which checks a file's character repertoire against a declaration the file makes about itself.

The check is opt-in and declaration-driven. With no marker, the charset is not checked. One HTML comment on one of the first five lines turns it on and names the allowed sets:

<!-- tackbox: chars=cyrillic,punct -->

With a marker present, every codepoint must be in the always-allowed ASCII base (U+0000-U+007F) or in one of the declared sets; anything else is a finding. The sets are named by character repertoire, not language:

  • ascii adds nothing (it declares the check with no extension);
  • cyrillic adds the Cyrillic block U+0400-U+04FF;
  • punct adds em/en dash, guillemets, ellipsis, curly and low quotes, and NBSP.

Sets are comma-joined and unioned; a space after a comma is fine (chars=ascii, cyrillic). Russian prose declares chars=cyrillic,punct; a grep-friendly zone declares chars=cyrillic.

The marker strengthens the check, so it is not a suppression: it draws no approval and does not appear in the escapes inventory. An invalid marker (an unknown set, an empty list, a duplicate set, a duplicate marker, or a marker past the fifth line) is itself a finding, and the content charset is then not checked - a broken declaration does not pass silently.

No configuration

By design, the ruleset is a single non-negotiable bundle. There are no flags to disable individual rules. Suppressing a finding requires the explicit per-site marker (// no-report, // parse-skip, // nil-return, // test-skip, // dup-ok) with a reason of at least 10 characters - non-empty was too cheap (ok / todo passed) - plus a covering line in the approval manifest below: the reason explains the exception, the manifest line records its approval.

Capture helpers are recognized by origin, not by name: a Go call counts only when its callee resolves (type info / import) to the github.com/nikitatsym/tackbox/go/report package, a JS/TS call to tackbox/report, and a Java capture when the caught reaches a nl.tsym.tackbox.report.Report call or a known logger sink (e.g. slf4j, java.lang.System.Logger) at ERROR / WARNING - tier-1. Every language also honors a function declared in a repo-root .tackbox/reporters file (file#function: reason) - tier-2. A declaration names a report sink - it is not an exclude: it disables no rule, and a declared call is honored only when the caught error flows into its arguments. Python resolves tier-1 by import origin too (D010), scoped to the fixed tackbox_report package (report_error / report_warn / report_quiet / report_panic / notify): a call counts only when it resolves through the module's own import bindings - from tackbox_report import ... or import tackbox_report (attribute form included) - so a same-named local def or a foreign import is not the verb. Only its tier-2 declarations stay matched by function name (any same-named call), not by resolving the callee to its file.

A [usage] declaration (file#function [usage]: reason) names the opposite lane: a deliberate user-facing diagnostic exit, e.g. a CLI usage() helper. It is never a capture. Its calls are clean outside err-branches (nothing failed - no marker needed) and a finding inside one (wrong sink for a failure path), regardless of arguments. Only erclint (ERC003) consumes usage sinks today, so a [usage] declaration on a non-Go file is rejected - a dead line would be silent. The format is language-uniform; the restriction lifts as other engines adopt the contract.

Suppression marker forms

Every marker is <keyword>: <reason> carried by the language's ordinary comment token:

  • Go, Java, JS/TS: a // line comment. Block comments are never markers; the one exception is dup-ok, where the duplication engine also accepts a standalone single-line /* ... */ (see its section).
  • Python: a # comment.
  • Svelte: inside <script> blocks the // form works as in JS/TS; the template adds two forms - a // comment inside a {...} expression (line-adjacent, as ever) and an HTML comment immediately above an element:
<!-- no-report: inline handler failure is tolerated here -->
<button onclick={...}>go</button>

The HTML-comment form suppresses within that whole element (deliberately wider than line adjacency: an inline handler can span lines - D011 A8) and stops at the element boundary; following siblings still report. <style> content takes no markers.

Placement per rule (above the try, standalone above the block, directly above the statement) is each rule's own contract; the 10-character reason floor (D009) is universal.

Approval manifest

Suppression markers are approved in one committed file, .tackbox/approvals at the repo root - one line per approved occurrence: an address (file plus named-scope chain) and the exact marker text.

py/app/svc.py#Handler.process: no-report: legacy path, covered upstream
js/src/boot.ts#init.<h4f2a9c1e>: no-report: splash fallback, reported upstream
tools/gen.py: parse-skip: config validated upstream

The chain walks functions, classes, methods, or Markdown headings, joined by .; an entry with no # sits at file scope. Anonymous scopes (lambdas, arrows, IIFEs) appear as 8-hex content hashes; Java overloads carry a parameter-type signature; same-name siblings take an @k ordinal. Repeat the line for each identical occurrence.

The check is bidirectional and always covers the whole tree: a marker without a covering entry and an entry without a live marker (an orphan) are both findings, reported by tackbox lint under the approvals (whole tree): header whatever the lint scope. tackbox approvals runs the same check standalone; tackbox approvals --draft prints a ready entry line for every uncovered marker - the address is computed for you, so approving a marker you just wrote is one append away, and bootstrapping a repo that already carries markers is: generate, review line by line, commit.

Approving is adding the line. In an agent session the edit that adds a manifest line draws the PreToolUse ask quoting the entry (several lines in one edit draw one all-or-nothing ask), so the only route to a green check passes through a visible diff and a human decision. Writing a marker itself never asks - by any channel, Edit or shell - it merely leaves the tree inconsistent, which every later hook event, dev.py check, and CI reports until the entry lands or the marker is reverted. Removing a manifest line is free; a marker whose text, scope, or count changes needs its entry updated the same way.

Generated and vendored code

Committed code that carries a generated or vendored git attribute is excluded from the whole lint - findings there are not fixable in the file (they belong in the generator), and a suppression marker cannot survive regeneration. tackbox honors exactly three attributes, linguist-generated, gitlab-generated, and linguist-vendored, read from .gitattributes the same way the host (GitHub, GitLab) reads them - no exclude surface of tackbox's own. The best fix stays organizational: generated code should normally not be committed at all; this serves the forced residue.

A file is excluded when git check-attr reports one of the three as set. Semantics by example:

gen/**       linguist-generated
vendor/**    linguist-vendored
gen/keep.go  linguist-generated=false

gen/** excludes everything under gen/; vendor/** the same for vendored. Note dir/**, not dir/ - gitattributes patterns, unlike gitignore, do not match a trailing-slash directory form. =false re-includes a single file inside an excluded tree (gen/keep.go above is linted normally); -attr and !attr also leave a file in. Only set / =true excludes.

The exclusion covers everything: per-file engines, the erclint Go package run (a mixed package's excluded file is compiled but its findings drop; a compile break still fails loudly), duplication, the CodeClimate report, and the marker inventory - an excluded file's markers are dead, so a manifest entry addressing one orphans. A lint run whose scope touches excluded files prints one summary line:

excluded by attributes: 12 files in scope (tackbox escapes lists all)

It counts unique excluded files in the current scope (absent at zero), so scoped runs are not wallpapered with a global constant; tackbox escapes lists the full population as attribute-excluded entries.

Because the excluded population is where the lint, the marker inventory, and host diff review are all blind, the agent hook makes the two ways into it loud:

  • adding a positive exclusion line (a bare <attr> or <attr>=true) to any .gitattributes draws a PreToolUse ask, one joint ask per edit listing every added line; removals, =false, -attr, and non-exclusion lines are free;
  • editing (or creating) a file that is effective-excluded draws an ask naming the attributes.

Generators run through Bash and are unaffected - the boundary is that the change stays in the commit/PR diff, and hosts collapse excluded-file diffs, so reviewers must expand them.

tackbox doctor adds an informational attributes section (never a check, no exit-code effect) naming local conditions that can make a run diverge from a clean CI clone - an info/attributes or an untracked/index-hidden .gitattributes carrier mentioning the attributes, or a neutralized attribute source override. tackbox escapes --since <rev> resolves the baseline's attributes as of the rev and so needs git >= 2.40 (older git is a named infra error on the --since path only; the plain listing needs no version bump).

Runtime reporting helpers

Direct reporting helpers ship per language; their shared runtime behavior - lane routing, telemetry dedup, panic grouping, and capture isolation - is specified in docs/report-contracts.md.

Agent hook

The rules wire into a coding agent's edit loop through one shared core: the approval gates, the diff-scoped lint, and the whole-tree consistency check are the same whichever host drives them. In host-neutral terms:

  • Post-edit re-lints the touched files (Go: their package). A finding on the lines the edit added blocks with the finding text. Every post event - including an opaque channel - also runs the whole-tree approvals consistency check: an unapproved marker, an orphaned entry, or an unresolvable file blocks with the named fix. A verified violation is always a tool error. A post event that cannot be verified reports three facts: the mutation may already have landed, why verification did not complete, and that the mutation must not be repeated before dev.py check. OMP appends that warning to the model-facing tool result without changing a successful tool state. Claude Code writes it to user-visible PostToolUse stderr with exit 1, so it is not model-visible.
  • Pre-edit asks for approval before a new .tackbox/approvals line or a new .tackbox/reporters line lands, before a positive exclusion line is added to any .gitattributes, before editing an attribute-excluded file, and before deleting or moving the root dev.py; removing a gate line is free. A known target whose content is ambiguous asks when it reaches a bypass surface. An unclassifiable file mutation or a failed policy dependency blocks before it can run; it is never weakened into an approval prompt.

Only markers in files an engine would lint participate in the check (D012): a marker in a Go testdata/ path or a non-lintable fixture extension (a .java.txt) is dead text - no entry needed, no question - while the .tackbox/reporters gate stays unconditional.

The hook is inactive only when git rev-parse --show-toplevel emits C-locale stderr containing not a git repository, or after its discovered root has no dev.py. A missing git executable, corrupt Git config, or another discovery failure is unverified, not a no-op.

Claude Code

tackbox hook reads a Claude Code hook event on stdin and dispatches by hook_event_name (PreToolUse, PostToolUse). Wire it once, globally, in ~/.claude/settings.json:

{
  "hooks": {
    "PreToolUse": [
      {"matcher": "Edit|Write|MultiEdit",
       "hooks": [{"type": "command", "command": "uvx tackbox hook"}]}
    ],
    "PostToolUse": [
      {"matcher": "Edit|Write|MultiEdit|Bash",
       "hooks": [{"type": "command", "command": "uvx tackbox hook", "timeout": 120}]}
    ]
  }
}

uvx tackbox hook runs the cached tackbox (no @latest): the hook is fast in-loop feedback, not the authoritative gate.

Oh My Pi

omp plugin install tackbox

OMP loads the ESM entry point js/omp/index.mjs, which delegates to the internal CommonJS implementation.

That is the whole wiring: the npm package declares an extension (package.json#omp.extensions) and OMP loads it. The extension subscribes to the public tool_call / tool_result events and covers all five OMP 18.x edit modes: replace, patch, hashline, apply_patch, and sloppy, including multi-file edits, clipboard registers, moves, and deletes. Its compatibility parser accepts U+00B6PATH#TAG headers plus sloppy [path], U+00A7path, and U+00A7*path section openers; bare U+00A7 forms continue the current file.

  • a pre-edit ask becomes a confirmation dialog. In a headless or subagent session, where nobody can answer, it blocks with the reason instead of approving itself; a denied ask blocks before the tool runs.
  • a verified post violation becomes a tool error carrying the findings, which keeps the agent in-loop on them.
  • an unverified pre event blocks. The child has a 20-second deadline inside OMP's 30-second handler budget. An unverified post event carries the shared three-fact warning, omits an isError override so OMP preserves the host state, and tells the model not to repeat the mutation before dev.py check.
  • an opaque write channel (xd:// tool devices, archive members, SQLite rows), every bash call, and every eval call name no file, so they run the whole-tree approvals wall alone.
  • MCP tool names are not enumerated by this extension. Their file mutations are an explicit residual outside its pre gate and post wall; review their diff and run dev.py check.
  • the post adapter consumes each result-detail record independently. It falls back to a snapshot only for that record when the record is pruned; failed records do not widen the scope of successful landed records. OMP 18.x does not identify a landed subset for a single aggregate error without per-file details, so Tackbox runs its whole-tree wall, preserves the host error, and cannot safely perform targeted lint for that residual.

The extension runs uvx tackbox@<npm package version> hook-protocol. A tagged wheel is built and protocol-canary tested, published to PyPI, then a successful release workflow automatically publishes the matching npm package from its immutable completed-run source. If a pending npm job is canceled, rerun publish from the Actions UI; there is no standalone npm redispatch.

For development against a working tree, name the command explicitly - a JSON array of argv, never a shell string:

TACKBOX_OMP_COMMAND='["uv","run","--directory","py","python","-m","tackbox.cli"]'

The subcommand is appended by the extension, never taken from the override, so a development command cannot answer a different protocol. There is no @latest fallback: an unpinned wheel could answer a protocol version the extension does not speak.

Other hosts

tackbox hook-protocol is the host-neutral wire - one JSON event on stdin, one JSON decision on stdout:

{"protocol": 1, "phase": "pre", "cwd": "/repo", "tool": "edit",
 "targets": [{"path": "/repo/app/svc.py", "op": "edit",
              "expectedPresent": true,
              "added": ["x = 2"], "removed": ["x = 1"]}],
 "unknown": null}
{"protocol": 1, "decision": "ask",
 "reason": "approve suppression marker: app/svc.py: no-report: covered upstream"}
  • cwd is the host session's non-empty absolute working directory.
  • phase is pre (before the tool runs, still refusable) or post (after it landed). Pre requests omit succeeded; post requests require the boolean succeeded, so a failed tool is not misreported as a missing landed file.
  • tool is one of edit, apply_patch, write, bash, or eval. bash and eval are target-free wall-only channels.
  • a target is one file mutation with an absolute path, op, and expectedPresent. edit and write expect the path to exist, delete expects it absent, and a move reports an absent source plus a present destination. content is a full replacement; otherwise added and removed are text fragments. content and fragments are mutually exclusive. ambiguous: true means a known target needs whole-file treatment.
  • zero targets is the opaque channel: the whole-tree wall runs, nothing file-scoped does. unknown is a non-empty reason only when no concrete target can be named; it blocks pre and warns post.
  • the wire decisions are allow, ask, block, and warn. The semantic outcomes are inactive, allow, approval-required, violation, and unverified: unverified maps to block pre and warn post. Hosts must make a post warning visible without turning a successful mutation into a repeatable tool error.
  • exit is 0 whenever a decision was reached, whatever it says; 1 plus one stderr line means no decision (unreadable stdin, or a protocol version this tackbox does not speak).

Escapes inventory

tackbox escapes prints the repo's whole bypass surface as JSON on stdout - every place code legitimately steps off the paved road, in one cheap command that review tooling of any harness can consume (D013). It enumerates:

  • suppression markers (// no-report, // parse-skip, // nil-return, // long-comment, // test-skip, // dup-ok), each with its reason;
  • .tackbox/reporters declarations - the tier-2 sinks;
  • notify / quiet lane choices - the call sites of the user-lane-only notify and the telemetry-only quiet verbs.

It is an inventory, not a gate: it exits 0 whenever it runs, entries or not, and is not wired into dev.py check. The rules and the hook are the enforcement; this command is food for a reviewer (human or agent) who wants the escapes laid out without re-deriving them. Exit is nonzero (1, one stderr line) only for an infra error - a bad --since rev.

uvx tackbox@latest escapes
uvx tackbox@latest escapes --since origin/main --context 5

JSON contract

{
  "version": 2,
  "since": null,
  "entries": [
    {"kind": "attribute-excluded", "file": "gen/api.pb.go",
     "attribute": "linguist-generated"},
    {"kind": "marker", "file": "a/b.py", "line": 12,
     "text": "no-report: central boundary already captures it",
     "reason": "central boundary already captures it",
     "context": ["...", "...", "..."]},
    {"kind": "reporter-decl", "file": ".tackbox/reporters", "line": 2,
     "text": "src/app/errors.py#report_api_error: the API sink",
     "context": ["..."]},
    {"kind": "notify-site", "file": "js/foo.js", "line": 40,
     "text": "notify('offline', err, {}, 'net.offline')",
     "context": ["..."]},
    {"kind": "quiet-site", "file": "go/x.go", "line": 9,
     "text": "report.Quiet(ctx, ...)", "context": ["..."]}
  ],
  "counts": {"marker": 1, "reporter-decl": 1, "notify-site": 1,
             "quiet-site": 1, "attribute-excluded": 1}
}
  • version is the schema version (2); counts always carries all five kinds, even at zero, so consumers see a stable shape. Every count is an entry count except attribute-excluded, which counts unique files.
  • since echoes the --since rev, or null.
  • attribute-excluded entries carry only kind / file / attribute (no line or text): the whole file is the bypass, one entry per set attribute of the three (linguist-generated, gitlab-generated, linguist-vendored). See "Generated and vendored code".
  • text is the trimmed source line; for a marker it runs from the marker keyword to end of line.
  • reason (markers only) is what follows the keyword's colon, trimmed - possibly empty.
  • context is the surrounding source, --context N lines each side (default 3), inclusive of the entry line itself - the window [line-N, line+N], clipped at file edges, each line trimmed of trailing whitespace. It is plain source; the entry line is not marked.
  • entries are sorted by (file, kind, kind-subkey) - the subkey is (line, text) for the line-bearing kinds and (attribute,) for attribute-excluded.

Scope and detection

The scan covers the same lintable source set the linter would scan (the D012 predicate: extension match plus each engine's path filter, so a Go testdata/ file is out) minus the attribute-excluded files, plus the root .tackbox/reporters (every non-empty line is one declaration - the file has no comment syntax). An attribute-excluded file's own markers are dead, so it surfaces only as its attribute-excluded entries. notify / quiet call sites are detected textually per language (report_quiet / notify in Python, reportQuiet / notify in the JS family, .Quiet( / .Notify( in Go, .quiet( / .notify( in Java), word-boundaried so notifyAll( does not match. Textual detection can over-report (a match inside a comment or string counts) - that is fine: this is observability, not a lint.

--since <rev>

--since <rev> prints only entries new against <rev>, compared by content identity ((kind, file, text), or (kind, file, attribute) for attribute-excluded) - the same extraction run against the tree at <rev> (via git ls-tree + git show) subtracted, count aware, from the current tree's entries. The baseline is attribute-aware: it resolves the attributes as of <rev> (via the seam's git check-attr --source), so an attribute added since the rev reports its newly-excluded files, a removed one re-activates its markers as new (never a silent subtraction), and an unchanged one adds no noise. Because --source needs git >= 2.40, an older git is a named infra error on the --since path only (the plain listing needs no version bump). It over-reports on moved code (a new file path is a new identity) but never silently drops an entry - the conservative direction for a review aid. A bad rev is the other infra error: one stderr line, exit 1.

Layout

.tackbox/approvals                     # suppression-approval manifest
dev.py                                 # lint / test / e2e / check (dev-script)
hygiene.py                             # dev.py lint hygiene (conflict/yaml/ws/newline)
go.mod                                 # Go module
package.json                           # npm package (ESLint plugin + OMP extension + report helper)
eslint.config.preset.js                # default config used by tackbox-eslint bin
bin/tackbox-eslint.js                  # ESLint CLI wrapper with bundled preset
bin/tackbox-mdlint.js                  # markdownlint wrapper with bundled preset
go/
  cmd/erclint/                         # native Go analyzers (ERC001-009)
  cmd/erclint-opengrep/                # opengrep wrapper, embedded rule yamls
    rules/                             # exceptions-go (go-exit-in-recover)
  analyzers/                           # per-rule go/analysis packages
  internal/                            # markers + AST helpers
  report/                              # Go capture helper (Sentry/glitchtip)
java/
  pom.xml                              # Maven module -> shaded javalint.jar
  src/main/.../javalint/               # typed-AST analyzer (JV001-010)
    rules/                             # per-rule checkers
  report/                              # Java capture helper -> Maven Central io.github.nikitatsym:report
js/
  eslint-plugin.js                     # ESLint plugin entry
  rules/                               # 14 frontend rules
  markdownlint-rules/                  # custom markdownlint rules
  report.js                            # browser capture helper (@sentry/browser)
  omp/                                 # Oh My Pi extension (payload parser + hook-protocol client)
  tests/                               # RuleTester + node:test
py/
  tackbox/                             # lint / hook / doctor CLI, cache, engines
    pyrules/                           # flake8 TBX plugin (python exception rules)
  tackbox_report/                      # Python capture helper -> PyPI tackbox-report
  tests/                               # pytest suite
docs/
  publishing-helpers.md                # helper release runbook (PyPI + Maven Central)

Repo conventions

  • Versioned via git tags (vMAJOR.MINOR.PATCH); CI auto-bumps the patch tag on every green push to main and publishes the wheels. Consumers track @latest, never a pinned version.

Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distributions

No source distribution files available for this release.See tutorial on generating distribution archives.

Built Distributions

If you're not sure about the file name format, learn more about wheel file names.

tackbox-0.1.86-py3-none-win_amd64.whl (12.6 MB view details)

Uploaded Python 3Windows x86-64

tackbox-0.1.86-py3-none-manylinux_2_28_x86_64.whl (12.4 MB view details)

Uploaded Python 3manylinux: glibc 2.28+ x86-64

tackbox-0.1.86-py3-none-manylinux_2_28_aarch64.whl (11.3 MB view details)

Uploaded Python 3manylinux: glibc 2.28+ ARM64

tackbox-0.1.86-py3-none-macosx_11_0_arm64.whl (11.6 MB view details)

Uploaded Python 3macOS 11.0+ ARM64

File details

Details for the file tackbox-0.1.86-py3-none-win_amd64.whl.

File metadata

  • Download URL: tackbox-0.1.86-py3-none-win_amd64.whl
  • Upload date:
  • Size: 12.6 MB
  • Tags: Python 3, Windows x86-64
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/6.1.0 CPython/3.13.13

File hashes

Hashes for tackbox-0.1.86-py3-none-win_amd64.whl
Algorithm Hash digest
SHA256 4a6b62652c8be63c0de5e67c3309dfdff69ea0ec417febe07620b327ce1c7e30
MD5 ea432dfdb640793ea9e5886409b7ac5d
BLAKE2b-256 18517ac5a08f685a38fab5ce5ce027987c9f99942fff37219434f54f866357db

See more details on using hashes here.

Provenance

The following attestation bundles were made for tackbox-0.1.86-py3-none-win_amd64.whl:

Publisher: publish.yml on nikitatsym/tackbox

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

File details

Details for the file tackbox-0.1.86-py3-none-manylinux_2_28_x86_64.whl.

File metadata

File hashes

Hashes for tackbox-0.1.86-py3-none-manylinux_2_28_x86_64.whl
Algorithm Hash digest
SHA256 fef01621dab744f03f87745386702acd2870af2c815151f2e7dd542da36f4439
MD5 5b0df4568f8345f822245ba7554e8c8a
BLAKE2b-256 fd2d2c2a02f5f26cd9bb0be3043521ba928aba0e12166fe96b1f7d1e775b1eca

See more details on using hashes here.

Provenance

The following attestation bundles were made for tackbox-0.1.86-py3-none-manylinux_2_28_x86_64.whl:

Publisher: publish.yml on nikitatsym/tackbox

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

File details

Details for the file tackbox-0.1.86-py3-none-manylinux_2_28_aarch64.whl.

File metadata

File hashes

Hashes for tackbox-0.1.86-py3-none-manylinux_2_28_aarch64.whl
Algorithm Hash digest
SHA256 ac41eb1e0389c68bf95fb6c595ea90b84b0a657d385e8d0fd38853ffdb1e250f
MD5 583d42e163d1132aea8548e91963363e
BLAKE2b-256 d6d447bf1d5b8fdc323d6f14f4d516a547b69ccaf15932c103ffe3d554609ca1

See more details on using hashes here.

Provenance

The following attestation bundles were made for tackbox-0.1.86-py3-none-manylinux_2_28_aarch64.whl:

Publisher: publish.yml on nikitatsym/tackbox

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

File details

Details for the file tackbox-0.1.86-py3-none-macosx_11_0_arm64.whl.

File metadata

File hashes

Hashes for tackbox-0.1.86-py3-none-macosx_11_0_arm64.whl
Algorithm Hash digest
SHA256 be6f8a9b8a282185bab0e6ade8d7792d17767bce7ee2452a1fbd99b8c5541fee
MD5 8ba95260a9a6c5be7bb5312b4bfd9625
BLAKE2b-256 ba7129133b3a89ee30b854de0a6dfc1350c82154bb4f07997ed5bed8ce633648

See more details on using hashes here.

Provenance

The following attestation bundles were made for tackbox-0.1.86-py3-none-macosx_11_0_arm64.whl:

Publisher: publish.yml on nikitatsym/tackbox

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

Release history Release notifications | RSS feed

0.1.97

4 files

0.1.96

4 files

0.1.95

4 files

0.1.94

4 files

0.1.93

4 files

0.1.92

4 files

0.1.91

4 files

0.1.90

4 files

0.1.89

4 files

0.1.88

4 files

0.1.87

4 files

This release

0.1.86 This release

4 files

0.1.83

4 files

0.1.82

4 files

0.1.81

4 files

0.1.80

4 files

0.1.79

4 files

0.1.78

4 files

0.1.77

4 files

0.1.76

4 files

0.1.75

4 files

0.1.74

4 files

0.1.73

4 files

0.1.72

4 files

0.1.71

4 files

0.1.70

4 files

0.1.69

4 files

0.1.68

4 files

0.1.66

4 files

0.1.65

4 files

0.1.64

4 files

0.1.63

4 files

0.1.62

4 files

0.1.61

4 files

0.1.60

4 files

0.1.59

4 files

0.1.58

4 files

0.1.57

4 files

0.1.56

4 files

0.1.55

4 files

0.1.54

4 files

0.1.53

4 files

0.1.52

4 files

0.1.51

4 files

0.1.50

4 files

0.1.49

4 files

0.1.48

4 files

0.1.47

4 files

0.1.46

4 files

0.1.45

4 files

0.1.44

4 files

0.1.43

4 files

0.1.42

4 files

0.1.41

4 files

0.1.40

4 files

0.1.39

4 files

0.1.38

4 files

0.1.37

4 files

0.1.36

4 files

0.1.35

4 files

0.1.34

4 files

0.1.32

4 files

0.1.31

4 files

0.1.30

4 files

0.1.29

4 files

0.1.28

4 files

0.1.27

4 files

0.1.26

4 files

0.1.25

4 files

0.1.24

4 files

0.1.23

4 files

0.1.22

4 files

0.1.21

4 files

0.1.20

4 files

0.1.19

4 files

0.1.18

4 files

0.1.17

4 files

0.1.16

4 files

0.1.15

4 files

0.1.14

4 files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page