skill
immunize
A curated pattern library that stops AI coding assistants from repeating common runtime errors. No API key. No LLM calls at runtime.
Set up
Skills are installed by placing their folder where your agent loads skills.
The download comes from github.com/viditkbhatnagar/immunize itself, not from our server, so what you get is exactly what its author published.
Install instructions come from the README
README
synced Oct 23, 2026immunize
Curated, verified guardrails that stop AI coding assistants from repeating runtime errors.
Offline · Deterministic · Cross-tool · Team-shareable · No API key at runtime.
Table of Contents
- What is immunize?
- Why it exists
- How it compares
- Architecture at a glance
- End-to-end pipeline
- Quickstart
- The four trigger paths
- Pattern matching internals
- Verification harness
- Artifact injection
- Bundled pattern library
- CLI reference
- Configuration
- Project layout
- Data model
- Authoring new patterns
- State storage
- Security & privacy
- Continuous integration
- Roadmap
- Contributing
- License
What is immunize?
immunize is a Python CLI and pattern library that turns the runtime errors your AI coding assistant produces into verified, durable, cross-tool guardrails committed to your repository.
When a bash command fails inside Claude Code (or any shell wrapped via immunize run), immunize:
- Captures the failure payload.
- Matches it against a curated regex/heuristic library — no LLM call at runtime.
- Runs the matched pattern's pytest in a subprocess to confirm the fix is sound in this environment.
- Injects three artifacts into your repo:
- A Claude Code skill (
.claude/skills/immunize-<slug>/SKILL.md) - A Cursor rule (
.cursor/rules/<slug>.mdc) - A pytest regression test (
tests/immunized/<slug>/test_template.py)
- A Claude Code skill (
Commit them. Your team — and every AI session anyone runs against the repo — picks the immunity up automatically.
Mental model. Your immune system remembers pathogens it has met.
immunizedoes the same for AI coding assistants: once it sees an error, the AI never repeats that class of error in your project again.
Why it exists
AI assistants repeat the same mistakes every session. You fix the stale-closure bug in useEffect on Monday; by Wednesday the assistant has rewritten it in a different file. The model has no durable memory of what you corrected.
Existing answers each fall short:
| Approach | Limitation |
|---|---|
| Claude Code memory / Cursor rules | Single-tool. Per-user. Untested — no build-time check that the rule prevents the error it claims to. |
| "Don't do that again" in chat | Survives one session, dies on /clear or a new teammate joining. |
| Linters / type checkers | Catch what their authors imagined — not what the AI specifically over-produces. |
| Sentry-style triagers | Detect & diagnose, but emit no persistent rules other tools can read. |
immunize makes a different bet: a small, curated, verified library of patterns covering the mistakes that matter, shipping artifacts in formats every assistant already reads, with a pytest that proves each fix works.
How it compares
%%{init: {'theme':'base', 'themeVariables': {'fontFamily':'Inter, system-ui, -apple-system, sans-serif', 'primaryTextColor':'#0f172a', 'lineColor':'#94a3b8'}}}%%
flowchart LR
classDef tool fill:#ffffff,stroke:#64748b,stroke-width:1px,color:#0f172a
classDef hero fill:#1e3a8a,stroke:#0f172a,stroke-width:1.5px,color:#f8fafc
classDef cap fill:#f1f5f9,stroke:#cbd5e1,stroke-width:1px,color:#0f172a
subgraph Tools["Tools"]
direction TB
I["immunize"]:::hero
CB["Cursor Bugbot"]:::tool
CM["Claude Memory"]:::tool
SS["Sentry Seer"]:::tool
SA["Semgrep Assistant"]:::tool
CR["CodeRabbit"]:::tool
end
subgraph Caps["Capabilities"]
direction TB
C1["SKILL.md emit"]:::cap
C2["Cursor rule emit"]:::cap
C3["Verified pytest"]:::cap
C4["Cross-tool"]:::cap
C5["Team-shareable via git"]:::cap
C6["Zero API key at runtime"]:::cap
end
I --> C1 & C2 & C3 & C4 & C5 & C6
CM --> C1
CB --> C2
SS --> C2
SA --> C2
CR --> C5
style Tools fill:#f8fafc,stroke:#cbd5e1
style Caps fill:#f8fafc,stroke:#cbd5e1
Only immunize ships all six at once. Verified regression tests are the structural moat — every other tool in this space stops at "we found a problem"; immunize proves the fix.
Architecture at a glance
Three layers separate concerns cleanly: triggers, the engine, and the artifacts that land in your repo.
%%{init: {'theme':'base', 'themeVariables': {'fontFamily':'Inter, system-ui, -apple-system, sans-serif', 'primaryTextColor':'#0f172a', 'lineColor':'#64748b'}, 'flowchart': {'curve':'basis'}}}%%
flowchart TB
classDef trigger fill:#ffffff,stroke:#475569,stroke-width:1px,color:#0f172a
classDef engine fill:#1e293b,stroke:#0f172a,stroke-width:1px,color:#f8fafc
classDef store fill:#334155,stroke:#0f172a,stroke-width:1px,color:#f8fafc
classDef artifact fill:#f1f5f9,stroke:#475569,stroke-width:1px,color:#0f172a
subgraph L1["Layer 1 · Triggers"]
direction LR
T1["Claude Code hook<br/>PostToolUseFailure"]:::trigger
T2["Shell wrapper<br/>immunize run <cmd>"]:::trigger
T3["Bundled skill<br/>immunize-manager"]:::trigger
T4["Manual CLI<br/>immunize capture"]:::trigger
end
subgraph L2["Layer 2 · Engine"]
direction LR
E1["capture.py"]:::engine --> E2["matcher.py"]:::engine
E2 --> E3["verify.py"]:::engine
E3 --> E4["inject.py"]:::engine
E5[("SQLite<br/>.immunize/state.db")]:::store
E1 -. persist .-> E5
E4 -. record .-> E5
end
subgraph L3["Layer 3 · Generated artifacts"]
direction LR
A1[".claude/skills/immunize-<slug>/<br/>SKILL.md"]:::artifact
A2[".cursor/rules/<slug>.mdc"]:::artifact
A3["tests/immunized/<slug>/<br/>test_template.py"]:::artifact
end
L1 --> L2 --> L3
style L1 fill:#f8fafc,stroke:#cbd5e1
style L2 fill:#f8fafc,stroke:#cbd5e1
style L3 fill:#f8fafc,stroke:#cbd5e1
- Layer 1 is plural by design. A single failure can arrive from a Claude Code hook, a
immunize runsubprocess, the bundled skill nudging the model, or a manual JSON pipe. - Layer 2 is one pipeline: capture → match → verify → inject. Pure Python. No network, no API key.
- Layer 3 is the durable output — committed to git, picked up automatically on
git pullby every assistant the team uses.
End-to-end pipeline
The same five-step pipeline runs no matter which trigger fires:
%%{init: {'theme':'base', 'themeVariables': {'fontFamily':'Inter, system-ui, -apple-system, sans-serif', 'actorBkg':'#1e293b', 'actorTextColor':'#f8fafc', 'actorLineColor':'#475569', 'signalColor':'#475569', 'signalTextColor':'#0f172a', 'labelBoxBkgColor':'#f1f5f9', 'labelBoxBorderColor':'#cbd5e1', 'labelTextColor':'#0f172a', 'noteBkgColor':'#f1f5f9', 'noteBorderColor':'#cbd5e1', 'noteTextColor':'#0f172a', 'sequenceNumberColor':'#f8fafc'}}}%%
sequenceDiagram
autonumber
participant Tool as AI tool / shell
participant CLI as immunize CLI
participant Match as matcher
participant Verify as verify
participant Inject as inject
participant Repo as Repository
participant DB as state.db
Tool->>CLI: failure JSON on stdin (Bash exit ≠ 0)
CLI->>DB: persist CapturePayload
CLI->>Match: load_patterns and match
Match-->>CLI: ranked MatchResult[]
alt no pattern clears threshold
CLI-->>Tool: outcome unmatched
else top pattern clears
CLI->>Verify: pytest in subprocess (swap fix → repro)
alt verify fails
CLI-->>Tool: outcome matched_verify_failed
else verify passes
CLI->>Inject: atomic write 3 artifacts
Inject->>Repo: SKILL.md + cursor rule + test_template
CLI->>DB: insert artifacts row
CLI-->>Tool: outcome matched_and_verified
end
end
Stdout is a strict one-line JSON contract; Rich console output goes to stderr. Hook handlers can trust the shape.
Decision tree at the matcher
%%{init: {'theme':'base', 'themeVariables': {'fontFamily':'Inter, system-ui, -apple-system, sans-serif', 'primaryTextColor':'#0f172a', 'lineColor':'#64748b'}}}%%
flowchart TD
classDef step fill:#ffffff,stroke:#475569,stroke-width:1px,color:#0f172a
classDef gate fill:#f1f5f9,stroke:#475569,stroke-width:1px,color:#0f172a
classDef ok fill:#1e3a8a,stroke:#0f172a,stroke-width:1px,color:#f8fafc
classDef drop fill:#f8fafc,stroke:#94a3b8,stroke-width:1px,color:#475569,stroke-dasharray:3 3
A["CapturePayload arrives"]:::step --> B["Compile regex per pattern · cached by pattern.id"]:::step
B --> C["Score = stderr + stdout + class hint + language"]:::step
C --> D{"≥ per-pattern<br/>min_confidence?"}:::gate
D -- no --> E["Drop"]:::drop
D -- yes --> F{"≥ global floor<br/>IMMUNIZE_MIN_MATCH_CONFIDENCE?"}:::gate
F -- no --> E
F -- yes --> G["Sort desc by confidence"]:::step
G --> H["Take top 1"]:::step
H --> I["Verify in subprocess"]:::ok
I -- pass --> J["Inject and persist"]:::ok
I -- fail --> K["Emit matched_verify_failed"]:::drop
E --> L["Emit unmatched"]:::drop
Each gate is documented in src/immunize/matcher.py and src/immunize/cli.py.
Quickstart
pip install immunize # core wheel — zero LLM dependencies
cd your-project
immunize install-skill # drops .claude/skills/immunize-manager/SKILL.md
immunize install-hook # registers .claude/settings.json PostToolUseFailure hook
Restart Claude Code. From this point on, every failed Bash tool call inside Claude Code feeds immunize capture automatically. When a known error class hits — AttributeError: 'NoneType', ModuleNotFoundError, a 429 crash, a CORS preflight rejection — three files appear in your repo. Commit them.
Outside Claude Code (Cursor, plain shells, CI):
immunize run pytest tests/
immunize run npm test
immunize run python manage.py migrate
immunize run tees output live, propagates exit codes, and on non-zero exit feeds the same matcher pipeline.
Windows note. A user-scope
pip install immunizeputsimmunize.exein%APPDATA%\Python\Python<ver>\Scripts\, which is not onPATHby default. The hook command Claude Code runs is the bareimmunize capture …, so the hook can't reach the executable until that Scripts directory is onPATH. The cleanest fix is to install inside a venv (py -m venv .venv && .venv\Scripts\activate && pip install immunize); the next-cleanest is adding the Scripts directory to userPATH(System Properties → Environment Variables, orsetx PATH "%PATH%;%APPDATA%\Python\Python<ver>\Scripts"). Verify withwhere immunizebefore runninginstall-hook.
The four trigger paths
Four entry points, one engine. Picking the right one is just a matter of where your failures originate.
%%{init: {'theme':'base', 'themeVariables': {'fontFamily':'Inter, system-ui, -apple-system, sans-serif', 'primaryTextColor':'#0f172a', 'lineColor':'#64748b'}}}%%
flowchart LR
classDef src fill:#ffffff,stroke:#475569,stroke-width:1px,color:#0f172a
classDef trg fill:#f1f5f9,stroke:#475569,stroke-width:1px,color:#0f172a
classDef eng fill:#1e3a8a,stroke:#0f172a,stroke-width:1.5px,color:#f8fafc
subgraph In["Where the failure happens"]
direction TB
F1["Claude Code · Bash tool"]:::src
F2["Cursor / plain shell"]:::src
F3["Claude Code · no hook configured"]:::src
F4["Existing log or scripted replay"]:::src
end
subgraph Triggers["Trigger path"]
direction TB
T1["PostToolUseFailure hook<br/>--source claude-code-hook"]:::trg
T2["immunize run <cmd><br/>--source shell-wrapper"]:::trg
T3["immunize-manager skill<br/>nudges Claude to call"]:::trg
T4["echo '{...}' | immunize capture<br/>--source manual"]:::trg
end
F1 --> T1
F2 --> T2
F3 --> T3
F4 --> T4
T1 & T2 & T3 & T4 --> ENGINE[("capture → match → verify → inject")]:::eng
style In fill:#f8fafc,stroke:#cbd5e1
style Triggers fill:#f8fafc,stroke:#cbd5e1
| Path | Set up by | Streams output? | Best for |
|---|---|---|---|
| Hook | immunize install-hook | n/a (Claude shows it) | Zero-touch automation in Claude Code |
immunize run | nothing — invoke per command | Yes (live tee) | CI, Cursor, bare shells |
| Bundled skill | immunize install-skill | n/a | Falls back to immunize run when no hook |
Manual capture | hand-built JSON | n/a | Replays, scripted captures, testing |
The hook payload is translated by payload_from_claude_code_hook into a CapturePayload — non-Bash failures are skipped cleanly, never producing noisy unmatched captures.
Pattern matching internals
A Pattern is a five-file directory under src/immunize/patterns/<slug>/ and a YAML descriptor. The matcher scores each candidate by combining four signals:
%%{init: {'theme':'base', 'themeVariables': {'fontFamily':'Inter, system-ui, -apple-system, sans-serif', 'primaryTextColor':'#0f172a', 'lineColor':'#64748b'}}}%%
flowchart LR
classDef input fill:#1e293b,stroke:#0f172a,stroke-width:1px,color:#f8fafc
classDef signal fill:#ffffff,stroke:#475569,stroke-width:1px,color:#0f172a
classDef agg fill:#f1f5f9,stroke:#475569,stroke-width:1px,color:#0f172a
classDef gate fill:#ffffff,stroke:#1e3a8a,stroke-width:1.5px,color:#0f172a
classDef ok fill:#1e3a8a,stroke:#0f172a,stroke-width:1px,color:#f8fafc
classDef drop fill:#f8fafc,stroke:#94a3b8,color:#475569,stroke-dasharray:3 3
P["CapturePayload<br/>stderr + stdout"]:::input --> S1["stderr regex<br/>min 0.6, +0.3 each"]:::signal
P --> S2["stdout regex<br/>min 0.4, +0.2 each"]:::signal
P --> S3["error class hint<br/>+0.15 if match"]:::signal
P --> S4["language detection<br/>+0.10 if pattern lang ∈ detected"]:::signal
S1 & S2 & S3 & S4 --> SUM[["Σ capped at 1.0"]]:::agg
SUM --> CMP{"≥ pattern.min_confidence<br/>and global floor?"}:::gate
CMP -- yes --> RANK["ranked candidate"]:::ok
CMP -- no --> DROP["dropped"]:::drop
Implementation references:
- Score breakdown —
score_pattern - Error class keyword set —
ERROR_CLASS_HINTS(cors,import,auth,rate_limit,type_error,null_ref,config,network) - Language signatures —
_LANGUAGE_SIGNATURES(python,javascript,typescript,go,rust) - Word-bounded keyword regex — fixes the latent collision where
ENOTFOUNDsubstring-matched insideModuleNotFoundError. - Float epsilon (
1e-9) — defends0.3 + 0.15 ≥ 0.45from IEEE-754 imprecision.
Two thresholds, one authoritative source
| Layer | Field | Default | Purpose |
|---|---|---|---|
| Per-pattern | pattern.match.min_confidence | varies (0.30–0.50) | Authored against real-world stderr |
| Global floor | Settings.min_match_confidence | 0.30 | Operator escape hatch (raise via IMMUNIZE_MIN_MATCH_CONFIDENCE for CI strict mode) |
Pre-v0.2.0 the global floor was 0.70 and silently shadowed every per-pattern threshold below it; calibration data and the rationale live in _planning/MATCHER_CALIBRATION_V020.md.
Verification harness
A pattern only ships if its pytest fails without the fix and passes with it. The harness re-checks the passes-with-fix half on the user's machine before injecting, catching environment drift (missing optional deps, pytest version skew):
%%{init: {'theme':'base', 'themeVariables': {'fontFamily':'Inter, system-ui, -apple-system, sans-serif', 'primaryTextColor':'#0f172a', 'lineColor':'#64748b'}}}%%
flowchart TB
classDef step fill:#ffffff,stroke:#475569,stroke-width:1px,color:#0f172a
classDef gate fill:#f1f5f9,stroke:#475569,stroke-width:1px,color:#0f172a
classDef ok fill:#1e3a8a,stroke:#0f172a,stroke-width:1.5px,color:#f8fafc
classDef bad fill:#f8fafc,stroke:#94a3b8,color:#475569,stroke-dasharray:3 3
Start(["verify.verify pattern"]):::ok --> Has{"fixtures/ has<br/>repro.* + fix.* pair?"}:::gate
Has -- no --> Run["Run pytest as-is"]:::step
Has -- yes --> Backup["Read repro.* bytes into memory"]:::step
Backup --> Swap["Write fix.* bytes over repro.* path"]:::step
Swap --> Run
Run --> Sub["python -m pytest -x -q<br/>cwd = pattern dir · timeout = 30s"]:::step
Sub --> Code{"exit code?"}:::gate
Code -- "0" --> Pass(["passed = True"]):::ok
Code -- "1, 2, 5" --> Fail(["passed = False<br/>+ truncated diagnostic"]):::bad
Code -- "TimeoutExpired" --> TO(["passed = False<br/>error: pytest timed out"]):::bad
Sub --> Restore["finally: write original repro.* bytes back"]:::step
Restore --> Pass
Restore --> Fail
Restore --> TO
Reference: verify.verify (user-runtime swap), scripts/pattern_lint.py (CI dual-run: FAIL → PASS → FAIL-after-restore).
The CI path is stricter than runtime — it runs the full three-phase swap so a broken pattern can never be merged in the first place.
Artifact injection
Three files land in fixed paths inside the user's project, each via an atomic write (PID-suffixed temp + os.replace):
%%{init: {'theme':'base', 'themeVariables': {'fontFamily':'Inter, system-ui, -apple-system, sans-serif', 'primaryTextColor':'#0f172a', 'lineColor':'#64748b'}}}%%
flowchart LR
classDef src fill:#1e293b,stroke:#0f172a,stroke-width:1px,color:#f8fafc
classDef art fill:#ffffff,stroke:#475569,stroke-width:1px,color:#0f172a
classDef out fill:#f1f5f9,stroke:#475569,stroke-width:1px,color:#0f172a
Pattern[("src/immunize/patterns/<slug>/")]:::src
Pattern --> SKILL["SKILL.md"]:::art --> O1[".claude/skills/immunize-<slug>/SKILL.md"]:::out
Pattern --> CR["cursor_rule.mdc"]:::art --> O2[".cursor/rules/<slug>.mdc"]:::out
Pattern --> TEST["test_template.py"]:::art --> O3["tests/immunized/<slug>/test_template.py"]:::out
Pattern --> FIX["fixtures/repro.* + fix.*"]:::art --> O4["tests/immunized/<slug>/fixtures/<br/>(repro slot receives FIX bytes)"]:::out
Pattern --> SG["semgrep.yml · optional"]:::art --> O5[".semgrep/<slug>.yml"]:::out
Two subtleties matter and are guarded by tests:
- Repro-slot rewrite.
test_template.pyreadsfixtures/repro.*. If we copied the pattern's buggy repro verbatim, the injected guardrail would fail against itself._copy_fixtures_with_repro_rewritewrites the fix bytes into the repro path — keeping the test's path expression unchanged while shipping a passing regression test. Source-tree repros stay intact forpattern_lint. - Slug collision.
resolve_slugprobes 99 candidates (base,base-2, …,base-99) against both the SQLiteartifactstable and the on-disk paths. If exhausted,SlugExhaustedErrorinstructs the user to runimmunize remove.
Bundled pattern library
Eleven patterns ship as of the latest main. The original seven were calibrated against 35+ real-world stderr samples; per-pattern recall details and the false-positive audit live in _planning/MATCHER_CALIBRATION_V020.md.
| ID | Languages | Class | Threshold | Catches |
|---|---|---|---|---|
react-hook-missing-dep | js, ts | lint | 0.30 | useEffect/useCallback/useMemo referencing reactive values not in deps array (matches the canonical react-hooks/exhaustive-deps rule ID) |
fetch-missing-credentials | js, ts | cors | 0.45 | Cross-origin authenticated fetch without credentials: 'include' (Chrome + Firefox phrasings) |
python-none-attribute-access | python | null_ref | 0.30 | AttributeError: 'NoneType' object has no attribute … and TypeError: 'NoneType' object is not subscriptable |
import-not-found-python | python | import | 0.50 | ModuleNotFoundError and ImportError: cannot import name |
missing-env-var | python | config | 0.40 | KeyError: 'UPPER_SNAKE' from os.environ['…'] access |
rate-limit-no-backoff | python | rate_limit | 0.45 | 429 Too Many Requests, RateLimitError, HTTPError … 429, rate_limit_error SDK responses |
async-fn-called-without-await | python | async | 0.30 | coroutine '<name>' was never awaited |
timezone-naive-datetime | python | type_error | 0.40 | TypeError: can't compare/subtract offset-naive and offset-aware datetimes (e.g., comparing datetime.now() with an HTTP API timestamp) |
node-cjs-esm-mismatch | js, ts | module | 0.30 | Node module mixes CommonJS require() with ES Module syntax — Cannot use import statement outside a module and require is not defined in ES module scope |
promise-unhandled-rejection | js, ts | async | 0.30 | Promise chain or await with no rejection handler — UnhandledPromiseRejection, Unhandled promise rejection, Uncaught (in promise) … |
json-decode-no-handling | python | parse | 0.40 | json.loads() / response.json() on untrusted input without try/except — leaks raw json.decoder.JSONDecodeError across the API boundary |
Each directory contains:
<slug>/
├── pattern.yaml # metadata + match rules + verification config
├── SKILL.md # Claude Code skill (frontmatter: name, description)
├── cursor_rule.mdc # Cursor rule (frontmatter: description, globs, alwaysApply)
├── test_template.py # pytest assertion that proves the fix
└── fixtures/
├── repro.<ext> # buggy form — test fails against this
└── fix.<ext> # correct form — test passes against this
CLI reference
immunize ships eight commands. Help on each: immunize <cmd> --help.
%%{init: {'theme':'base', 'themeVariables': {'fontFamily':'Inter, system-ui, -apple-system, sans-serif', 'primaryTextColor':'#0f172a', 'lineColor':'#94a3b8'}}}%%
flowchart LR
classDef capture fill:#1e3a8a,stroke:#0f172a,stroke-width:1px,color:#f8fafc
classDef mgmt fill:#334155,stroke:#0f172a,stroke-width:1px,color:#f8fafc
classDef setup fill:#0f766e,stroke:#0f172a,stroke-width:1px,color:#f8fafc
classDef contrib fill:#475569,stroke:#0f172a,stroke-width:1px,color:#f8fafc
subgraph Cap["Capture"]
direction TB
capture["capture"]:::capture
run["run"]:::capture
end
subgraph Manage["Manage"]
direction TB
list["list"]:::mgmt
verify["verify"]:::mgmt
remove["remove"]:::mgmt
end
subgraph Setup["Setup"]
direction TB
install_skill["install-skill"]:::setup
install_hook["install-hook"]:::setup
end
subgraph Contribute["Contribute"]
direction TB
author_pattern["author-pattern"]:::contrib
end
style Cap fill:#f8fafc,stroke:#cbd5e1
style Manage fill:#f8fafc,stroke:#cbd5e1
style Setup fill:#f8fafc,stroke:#cbd5e1
style Contribute fill:#f8fafc,stroke:#cbd5e1
| Command | Purpose |
|---|---|
immunize capture [--source S] [--stdin-plain] [--dry-run] | Match → verify → inject from a JSON or plain-stderr stdin payload. Emits one JSON line on stdout. |
immunize run [--timeout N] [--no-capture] -- <cmd> [args] | Spawn <cmd>, tee output live, propagate exit code, auto-capture on non-zero exit. |
immunize list | Table of every immunity active in the project. |
immunize verify [<id|slug>] | Re-run pytest against one (or all) injected immunities. |
immunize remove <id|slug> [--yes] | Delete artifacts and SQLite row for an immunity. |
immunize install-skill [--project-dir PATH] [--force] | Copy bundled immunize-manager skill into .claude/skills/. |
immunize install-hook [--project-dir PATH] [--force] | Register the PostToolUseFailure hook in .claude/settings.json. |
immunize author-pattern --from-error E.json --output DIR [--model M] | Contributor-only. Drafts a new bundled pattern via Claude API; lives behind pip install 'immunize[author]'. |
immunize capture JSON output contract
Exactly one of these shapes on stdout (always exit 0 unless the CLI itself is broken):
// matched + verified → 3 files written
{
"outcome": "matched_and_verified",
"matched": true,
"verified": true,
"pattern_id": "python-none-attribute-access",
"pattern_origin": "bundled",
"confidence": 0.55,
"artifacts": {
"skill": "/abs/.claude/skills/immunize-python-none-attribute-access/SKILL.md",
"cursor_rule": "/abs/.cursor/rules/python-none-attribute-access.mdc",
"pytest": "/abs/tests/immunized/python-none-attribute-access/test_template.py"
}
}
// matched but verify failed in this env (e.g. pytest missing) → no files written
{ "outcome": "matched_verify_failed", "matched": true, "verified": false,
"pattern_id": "...", "pattern_origin": "bundled",
"confidence": 0.55, "reason": "pytest is not installed in this environment. Run: pip install pytest" }
// no pattern cleared threshold
{ "outcome": "unmatched", "matched": false, "can_author_locally": true }
// hook fired but it wasn't a Bash failure
{ "outcome": "skipped", "reason": "non-Bash tool failure", "tool_name": "Edit" }
Rich-formatted, human-readable output goes to stderr. Stdout is for machines.
Configuration
Settings resolve highest-priority first:
%%{init: {'theme':'base', 'themeVariables': {'fontFamily':'Inter, system-ui, -apple-system, sans-serif', 'primaryTextColor':'#0f172a', 'lineColor':'#64748b'}}}%%
flowchart TB
classDef src fill:#ffffff,stroke:#475569,stroke-width:1px,color:#0f172a
classDef merge fill:#f1f5f9,stroke:#475569,stroke-width:1px,color:#0f172a
classDef out fill:#1e3a8a,stroke:#0f172a,stroke-width:1.5px,color:#f8fafc
A["CLI flags"]:::src --> M{"merge"}:::merge
B["Environment vars · IMMUNIZE_*"]:::src --> M
C[".immunize/config.toml"]:::src --> M
D["~/.config/immunize/config.toml"]:::src --> M
E["Built-in defaults"]:::src --> M
M --> S["Settings"]:::out
| Setting | TOML key | Env var | Default |
|---|---|---|---|
| LLM model (authoring only) | model | IMMUNIZE_MODEL | claude-sonnet-4-6 |
| Verify timeout (seconds) | [verify] timeout_seconds | IMMUNIZE_VERIFY_TIMEOUT_SECONDS | 30 |
| Verify retry count | [verify] retry_count | IMMUNIZE_VERIFY_RETRY_COUNT | 1 |
| Global match floor | [match] min_confidence | IMMUNIZE_MIN_MATCH_CONFIDENCE | 0.30 |
| Local patterns dir | [match] local_patterns_dir | IMMUNIZE_LOCAL_PATTERNS_DIR | .immunize/patterns_local/ |
| Generate semgrep YAML | [generate] semgrep | IMMUNIZE_GENERATE_SEMGREP | false |
Two operational toggles:
IMMUNIZE_DEBUG_HOOK=1— dump every Claude Code hook payload to.immunize/hook_payloads/<ts>-<session>.jsonfor offline inspection (auto-gitignored).IMMUNIZE_MIN_MATCH_CONFIDENCE=0.6— global floor knob; useful in CI strict mode.
Project layout
immunize/
├── pyproject.toml # hatchling build, runtime deps, [author]/[dev] extras
├── README.md # this file
├── CHANGELOG.md # Keep-a-Changelog formatted history
├── CONTRIBUTING.md # dev setup + pattern authoring workflow
├── LICENSE # Apache-2.0
├── Makefile # install / test / lint / format / build / clean
├── .pre-commit-config.yaml # ruff (check + format)
├── .github/workflows/
│ ├── ci.yml # pytest matrix (3.10/3.11/3.12) + pattern_lint
│ └── release.yml # tag-driven PyPI publish via OIDC
├── _planning/ # design docs (architecture, spec, calibration, …)
├── scripts/
│ └── pattern_lint.py # CI gate: structural + behavioral pattern check
├── src/immunize/
│ ├── __init__.py # __version__
│ ├── __main__.py # `python -m immunize`
│ ├── cli.py # Typer app + 8 commands
│ ├── capture.py # stdin parsing, hook translation, fingerprinting
│ ├── matcher.py # regex compile + score + threshold
│ ├── verify.py # pytest subprocess + fix→repro swap
│ ├── inject.py # atomic writes + slug collision resolution
│ ├── runner.py # `immunize run` subprocess wrapper (tee threads)
│ ├── hook_installer.py # idempotent merge into .claude/settings.json
│ ├── skill_install.py # copy bundled skill into project
│ ├── storage.py # SQLite schema, in-place migration, queries
│ ├── config.py # toml + env + CLI override merge
│ ├── models.py # Pydantic v2: CapturePayload, Pattern, Settings, …
│ ├── authoring/
│ │ └── cli_author.py # `author-pattern` (lazy-imports anthropic)
│ ├── patterns/ # 11 bundled patterns (slug-named dirs)
│ └── skill_assets/
│ └── immunize-manager/SKILL.md # bundled skill
└── tests/ # 19 test modules covering every module
Data model
Pydantic v2 models in src/immunize/models.py define the contract:
%%{init: {'theme':'base', 'themeVariables': {'fontFamily':'Inter, system-ui, -apple-system, sans-serif', 'primaryColor':'#f1f5f9', 'primaryBorderColor':'#475569', 'primaryTextColor':'#0f172a', 'lineColor':'#64748b'}}}%%
classDiagram
direction LR
class CapturePayload {
+Source source
+str|None tool_name
+str|None command
+str stdout
+str stderr
+int exit_code
+str cwd
+datetime timestamp
+str project_fingerprint
+str|None session_id
}
class Pattern {
+str id «kebab-case ≤40»
+int version
+int schema_version
+str author
+Literal origin «bundled|local|community»
+str error_class
+list~str~ languages
+str description
+MatchRules match
+Verification verification
+Path|None directory
}
class MatchRules {
+list~str~ stderr_patterns
+list~str~ stdout_patterns
+str|None error_class_hint
+float min_confidence
}
class Verification {
+str pytest_relative_path
+bool expected_fail_without_fix
+bool expected_pass_with_fix
+int timeout_seconds
}
class MatchResult {
+Pattern pattern
+float confidence
+list~str~ matched_stderr_patterns
+list~str~ matched_stdout_patterns
+dict score_breakdown
}
class Settings {
+str model
+bool generate_semgrep
+int verify_timeout_seconds
+int verify_retry_count
+Path project_dir
+Path state_db_path
+float min_match_confidence
+Path|None local_patterns_dir
}
class VerificationResult {
+bool passed
+str|None error_message
}
Pattern *-- MatchRules
Pattern *-- Verification
MatchResult o-- Pattern
Source enum: "claude-code-hook" | "shell-wrapper" | "manual". The CLI rejects any other value before persisting.
Authoring new patterns
Two paths produce the same five-file directory shape on disk:
Path 1 — LLM-assisted (contributor only)
pip install 'immunize[author]'
export ANTHROPIC_API_KEY=sk-ant-...
immunize author-pattern \
--from-error path/to/error.json \
--output src/immunize/patterns/
Internally:
%%{init: {'theme':'base', 'themeVariables': {'fontFamily':'Inter, system-ui, -apple-system, sans-serif', 'actorBkg':'#1e293b', 'actorTextColor':'#f8fafc', 'actorLineColor':'#475569', 'signalColor':'#475569', 'signalTextColor':'#0f172a', 'labelBoxBkgColor':'#f1f5f9', 'labelBoxBorderColor':'#cbd5e1', 'labelTextColor':'#0f172a', 'noteBkgColor':'#f1f5f9', 'noteBorderColor':'#cbd5e1', 'noteTextColor':'#0f172a'}}}%%
sequenceDiagram
participant CLI as author-pattern
participant API as Anthropic API
participant Tmp as scratch dir
participant Lint as pattern_lint
CLI->>API: 1. analysis call (slug + error_class + languages + regex + confidence)
API-->>CLI: tool_use response (forced JSON)
CLI->>API: 2. drafting call (skill, cursor rule, pytest, repro, fix)
API-->>CLI: tool_use response
CLI->>Tmp: write 5 files
CLI->>Lint: subprocess pattern_lint --patterns-dir scratch
alt verifier rejects
CLI->>API: 3. retry drafting with prior errors injected
API-->>CLI: revised draft
CLI->>Tmp: rewrite 5 files
CLI->>Lint: re-run
end
alt still failing
CLI->>CLI: dump to .immunize/rejected/<slug>/
else passing
CLI->>CLI: shutil.move scratch → output/<slug>/
end
The Anthropic SDK is only imported here. End-user installs of immunize never pull anthropic, httpx, or jiter — it is gated behind the [author] extra.
Path 2 — manual
Copy an existing pattern (e.g. python-none-attribute-access), adapt the YAML, SKILL, rule, test, and fixtures. Then:
python scripts/pattern_lint.py
pattern_lint enforces the Ten Commandments of pattern quality (full text in _planning/PATTERN_AUTHORING.md). The most consequential:
- A pattern earns its slug. Generic names rejected.
- Patterns test defaults, not knobs.
repro.*andfix.*must differ observably under the same test.- Stderr regex anchors on signal, not noise.
min_confidenceis honest.- SKILL.md teaches, doesn't lecture.
- No URLs in SKILL.md.
- Languages list is precise.
- Never wrap domain knowledge.
- If the test is flaky, the pattern is wrong.
State storage
A SQLite database at <project>/.immunize/state.db tracks captures and injections. Schema (see storage.py):
%%{init: {'theme':'base', 'themeVariables': {'fontFamily':'Inter, system-ui, -apple-system, sans-serif', 'lineColor':'#475569', 'primaryColor':'#f1f5f9', 'primaryBorderColor':'#475569', 'primaryTextColor':'#0f172a'}}}%%
erDiagram
errors ||--o{ artifacts : "fingerprints"
errors {
int id PK
text payload_json
text captured_at
text project_fingerprint
}
artifacts {
int id PK
text pattern_id
text pattern_origin
text slug
text skill_path
text cursor_rule_path
text semgrep_path
text pytest_path
int verified
text created_at
}
diagnoses {
int id PK
int error_id FK
text diagnosis_json
text model
text created_at
}
rejections {
int id PK
int diagnosis_id FK
text reason
text rejected_at
}
diagnoses survives from the pre-v0.1 LLM-at-runtime architecture for backward compat; the matcher pipeline writes only to errors and artifacts. An in-place migration in _migrate_artifacts_if_needed upgrades legacy schemas on first connect.
The DB is local state only — auto-.gitignored via the install-hook flow. Shared state is the committed artifact files.
Security & privacy
| Concern | Stance |
|---|---|
| LLM at runtime | Never. The matcher is regex; verify is pytest in subprocess; inject is os.replace. The Anthropic SDK is unreachable from the capture import graph. |
| Network calls | Never at user-runtime. author-pattern (contributor) is the sole exception, gated behind the [author] extra and an explicit ANTHROPIC_API_KEY. |
| Stderr exfiltration | Stderr is persisted only to local SQLite under .immunize/. Hook payload dumps are off by default and gitignored. |
| Atomic writes | Every artifact write is PID-suffixed temp + os.replace. Concurrent immunize processes (e.g. a hook firing during a manual capture) never observe a partial file. |
| Hook scope | We modify project-scope .claude/settings.json only, never user-scope ~/.claude/settings.json (a global hook would fire in unrelated projects). |
| Idempotence | install-hook and install-skill are no-ops on identical re-invocation; --force is required to overwrite drifted entries. |
| OS support | macOS, Linux, and Windows. The full pipeline (capture → match → verify → inject) and CLI run on all three; CI exercises every supported Python version on Linux + Windows. |
Continuous integration
%%{init: {'theme':'base', 'themeVariables': {'fontFamily':'Inter, system-ui, -apple-system, sans-serif', 'primaryTextColor':'#0f172a', 'lineColor':'#64748b'}}}%%
flowchart LR
classDef trig fill:#ffffff,stroke:#475569,stroke-width:1px,color:#0f172a
classDef job fill:#f1f5f9,stroke:#475569,stroke-width:1px,color:#0f172a
classDef step fill:#ffffff,stroke:#475569,stroke-width:1px,color:#0f172a
classDef ok fill:#1e3a8a,stroke:#0f172a,stroke-width:1.5px,color:#f8fafc
Push["push / PR"]:::trig --> CI["ci.yml"]:::job
Tag["tag v*"]:::trig --> Rel["release.yml"]:::job
CI --> Test["matrix 3.10 / 3.11 / 3.12<br/>pytest + ruff"]:::step
CI --> PLint["pattern_lint.py<br/>FAIL → PASS → FAIL-after-restore"]:::step
Test & PLint --> Green(("green")):::ok
Rel --> Build["python -m build"]:::step
Build --> Pub["pypa/gh-action-pypi-publish<br/>OIDC trusted publisher"]:::step
ci.yml— installs[dev]extras, runsruff check .,pytest, andpython scripts/pattern_lint.pyon three Python versions in parallel.release.yml— fires onv*tag pushes, builds wheel + sdist, and publishes to PyPI via OIDC trusted publishing (no API tokens stored).
Roadmap
Tracked in _planning/LAUNCH_LIBRARY.md and CHANGELOG entries.
| Version | Theme | Status |
|---|---|---|
v0.1.x | Core pipeline, 7 bundled patterns, manual capture | shipped |
v0.2.x | Claude Code hook automation, immunize run, calibrated matcher | current (latest tag v0.2.1) |
main (post-v0.2.1) | 4 new patterns: timezone-naive-datetime, node-cjs-esm-mismatch, promise-unhandled-rejection, json-decode-no-handling (library now 11) | merged, awaiting release |
v0.3.x | Native test runners (Jest/Go/cargo), more patterns | planned |
v0.4+ | Community pattern registry, IDE-side telemetry opt-in | exploratory |
Explicit non-goals: web dashboard, MCP server, always-on shell daemon, IDE extensions. The premise is to emit files the existing tools already read.
Contributing
Contributions — especially new patterns — are warmly welcomed. Start with CONTRIBUTING.md for dev setup and _planning/PATTERN_AUTHORING.md for the Ten Commandments.
git clone https://github.com/viditkbhatnagar/immunize.git
cd immunize
python -m venv .venv && source .venv/bin/activate
pip install -e ".[dev]"
pre-commit install
make test # full pytest suite
python scripts/pattern_lint.py # validate every bundled pattern
PRs land via GitHub. One pattern per PR. CI must be green.
License
Apache-2.0 — see LICENSE.
Built with Python 3.10+, Typer, Pydantic v2, Rich, and pytest. No LangChain. No daemons. No telemetry.