CLAUDE.md · git:20260925.27f0518 · 2026-09-25 · sha256 04b40bee9b937d6c

CLAUDE.md git:20260925.27f0518A

Immutable. This exact content never changes and is served at /api/v1/blob/04b40bee9b937d6c.

# CLAUDE.md

This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. Shared cross-agent rules live in `AGENTS.md`; this file adds the Claude Code specifics.

## What This Repo Is

A hostile audit toolkit shipped as **one skill** — `nitpicker` — invoked as
`/nitpicker <command> [extra instructions]`.

The router is `skills/nitpicker/SKILL.md`. Each command's instructions live in
`skills/nitpicker/commands/<command>.md`, with shared conventions in
`commands/_conventions.md`.

The repo is installable as a Claude Code plugin via `/plugins`, and into
Copilot, pi and other agents via `npx skills add ivuorinen/skills` (open Agent
Skills format). Internal dev skills — scaffolding, validation, release — live
under `.claude/skills/` and are not shipped to consumers.

## Context Discipline

`_conventions.md` carries only what binds every command. Four protocols load on a
trigger instead — `_findings-store` before the first store operation,
`_committing` before a commit, `_documentation` before a fix or a `docs` finding,
`_audit-coverage` for `audit`. Each trigger is stated in `_conventions.md` and in
SKILL.md's execution order, and both are binding: a protocol not loaded is a
protocol not followed. Rules that hold in *every* run — the finding contract,
redaction, the migration consent gate, the run protocol's shape — stay in the
core file rather than moving with their protocol.

`skills/nitpicker/scripts/context_pack.py` (MCP: `np_context_pack`) is the portable context
firewall. It answers with coordinates — path, line range, enclosing symbol, why
it matched — not with file bodies. Its modes are the acquisition ladder:
`inventory` (A), `symbols` and `diff` (B), `evidence` (C). Level D is a direct
read the caller performs after the pack narrows it. The invariant the callers
are held to: **compression may decide what to inspect next; only original source
may prove a finding.** `--self-test` runs the known-positive controls, because a
retriever returning nothing is otherwise indistinguishable from a repository
containing nothing.

`skills/nitpicker/scripts/check-context-tokens.py` reports the size of the set loaded on every
turn and of one invocation. It is CLI-only and **cannot fail a build**: its
numbers are four-characters-per-token estimates, and gating on an estimate turns
an approximation into a rule nobody can reproduce. Read the `--baseline` delta,
not the absolute. `check-agent-instructions.py` remains the directive-count gate;
the two measure different halves of the same budget.

## Development Commands

```bash
make check        # the full gate; run before every commit. `make help` lists its targets
make validate     # SKILL.md + command-file structure (public + internal)
make validate-evals # shape of each skill's evals/*.json (evals, trigger-queries, pressure-records)
make ring-deps    # print the module dependency graph; fail on an outward (inner→outer) edge
make test         # run pytest unit tests
make list         # list the skill and its commands
make lint         # ruff check on scripts/, tests/, skills/
make format       # ruff format on scripts/, tests/, skills/
make security     # bandit scan of skills/ and scripts/ (config in [tool.bandit])
make opengrep     # opengrep scan (the rules Codacy reports) + stale-suppression check
make agentlinter  # agentlinter scan (Codacy's other engine) against the committed baseline
```

## Commands

The authoritative command listing (categorized, with aliases) is `## Commands` in `skills/nitpicker/SKILL.md`; `/nitpicker help` prints it. The 1.x standalone skill names (`security-auditor`, `test-auditor`, …) are aliases of the new short names (`security`, `tests`, …).

## Agent Skills Spec Compliance

The normative format is the open spec at <https://agentskills.io/specification>.
`scripts/validate-skill.py` enforces it. Required: `name` (≤64 chars,
lowercase/digits/hyphens, no leading, trailing or consecutive hyphen, matching
its directory) and `description` (≤1024 chars). Optional: `license`,
`compatibility` (≤500 chars), `metadata` (a string→string map) and
`allowed-tools` (one space-separated string).

A top-level key outside that set is an **error**. Client-specific properties go
under `metadata`, which is what the spec designates it for. There is no
allowlist and no exemption; internal dev skills are held to the same rule. Body
size warns past 500 lines or ~5000 tokens, the progressive-disclosure
instructions tier.

`disable-model-invocation` therefore lives under `metadata` as `"true"`, quoted
because metadata values are strings. Claude Code reads that key from the top
level only, so under `metadata` it is inert and those skills become
model-invocable. That trade was accepted deliberately, buying portability.

Each skill's eval sets live in `<skill-dir>/evals/` — `evals.json` (output-quality cases with gradable assertions), `trigger-queries.json` (description trigger accuracy, fixed train/validation split) and `pressure-records.json` (which commands were pressure-tested) — gated by `make validate-evals`. See `.claude/rules/skill-official-best-practices.md`.

`make spec-check` cross-checks every skill against the Agent Skills reference
validator. It needs network access, so it sits outside `make check`;
`validate-skill.py` enforces the same constraints offline. Every skill passed at
the time of writing, internal dev skills included — re-run it rather than
trusting that, since a spec release can change the verdict.

Three install traps, each hit once already:

- The PyPI package is `skills-ref`, but its console script is `agentskills`.
  The spec page still documents a `skills-ref` command, which no longer exists
  and exits 1 with no output.
- The identically-named npm package is unrelated to Anthropic.
- Failures print to stderr and successes to stdout, so merge stderr before
  judging a run clean.

## Command File Format

- Only the router `skills/nitpicker/SKILL.md` has YAML frontmatter (`name`, `description` with "Use when", ≤1024 chars, single-quoted when it contains ": ", plus `license` and `compatibility`).
- Command files have no frontmatter. Required shape: h1 `# /nitpicker <command> — <Title>` (must match the filename), a `## When to use` section, no header-level jumps. Enforced by `scripts/validate-skill.py`.
- Every command file in `commands/` whose name does not begin with `_` needs a
  row in one of SKILL.md's command tables (`## Commands` or
  `## Internal commands`), 1:1, enforced by `scripts/validate-skill.py`.
  Shared files prefixed `_` (`_conventions.md`, `_audit-coverage.md`) are the
  exception, and carry no row.
- No behavioral reliance on Claude-only features (`$ARGUMENTS`, `argument-hint`): arguments are parsed from the free text after the invocation so the skill works in Copilot and pi.

## Findings Store

One file per **open** finding under
`docs/audit/findings/<auditor>/open/<id>.md`. Resolving one appends a record to
the append-only `docs/audit/findings/resolved.jsonl` ledger and deletes the open
file, so the tree does not accumulate hundreds of resolved files.

`INDEX.md` is generated: the index (rebuilt by findings.py and by the
PostToolUse hook below) is tool output, not a file to hand-edit. An in-store
`.gitattributes`, self-written by findings.py, marks the store
`linguist-generated` so audit runs don't flood PR diffs.

The store is managed through the `np_*` MCP tools where the session exposes
them, else the shipped, stdlib-only CLI. `commands/_findings-store.md` maps
every operation to its interface and names the ones no tool wraps. Read it
there rather than keeping a second list here — that is how this paragraph came
to name three of the five.

```bash
python3 skills/nitpicker/scripts/findings.py --help    # every subcommand
```

IDs are content-hashed rather than hand-assigned. `new --force` re-opens a
resolved finding under the id it already had, dropping its ledger record; that
is the one route by which an id in the ledger comes back.

`migrate` converts 1.x `docs/audit/*-findings.md` documents. `migrate-resolved`
folds a legacy `<auditor>/resolved/*.md` tree into the ledger.

`export --format sarif|json|junit` (in `findings_export.py`) renders the store
for another system. The Static Analysis Results Interchange Format (SARIF) —
what a code-scanning UI ingests — omits resolved findings, since an alert on a
fixed defect is indistinguishable from a live one. JUnit maps open to failure
and resolved to pass, so a CI panel shows unfixed findings beside failing tests.

`new --location path:START-END` records where the evidence was read — START (the
first cited line) and END (the last) — plus a fingerprint of that source.
`recheck` re-computes every one, so `reverify` can skip a finding whose cited
bytes are unchanged rather than spend a model pass on it.

The fingerprint covers the cited line range, so an unrelated edit *above* it
reads as `changed`. That is the safe direction: it costs a re-check rather than
a missed change. `location` is deliberately outside the content-hashed id.

The PostToolUse hook `validate-audit-findings-hook.py` validates edited open
findings and the ledger, and regenerates the index.

## PR Fetchers

`cr` reads a PR's review surface through `fetch-pr-comments.py` and
`fetch-pr-status.py`, which cover GitHub, GitLab and Bitbucket Cloud behind one
JSON format; the MCP (Model Context Protocol) tools `np_pr_comments` and
`np_pr_status` wrap the same providers. `.claude/rules/pr-fetchers.md` loads
with those modules and holds the port/adapter split and the credential-pinning
invariants.

## Editing a shipped tool mid-session

The running MCP server holds the shipped modules it imported at startup, so an
edit under `skills/*/scripts/` does not change what it executes.
`.claude/rules/mcp-stale-server.md` loads with those files and names the
interface to use for the rest of the session.

## Script Execution

Two classes (see `.claude/rules/use-uv-runner.md`):

- **Shipped skill tools** (`skills/*/scripts/`): stdlib-only, plain `python3`, `#!/usr/bin/env python3`. The stdlib-only rule is enforced by `scripts/check-stdlib-only.py` (pre-commit + CI) — a third-party import fails the gate.
- **Internal dev tooling** (`scripts/`, `scripts/hooks/`, `tests/`): `uv run --quiet`, `#!/usr/bin/env -S uv run --quiet` + `# /// script` block.

Every shipped tool answers `--help`/`-h` with its interface on stdout at exit 0.
Structured data goes to stdout, diagnostics to stderr. Exit codes are distinct
per failure class: 0 success, 1 runtime or I/O error, 2 usage error.

Handle `--help` before any positional argument resolves as a path. Otherwise the
flag is read as input and the agent gets a path error in place of usage text.
The design rules live in `.claude/rules/use-uv-runner.md`; enforcement is author
discipline plus the per-tool `--help` tests.

## Suppression Markers

`# nosec` marks a bandit finding and `# nosemgrep` an opengrep one; `make
opengrep` reproduces Codacy's opengrep findings and fails on a `# nosemgrep`
that suppresses nothing. Locally the target skips when opengrep is absent; the
`Validate` workflow installs a pinned binary, so under CI it fails instead.
`.claude/rules/suppression-markers.md` loads with `scripts/` and `skills/` and
holds the placement rule and the gate's limits.

## The Agentlinter Baseline

`make agentlinter` reproduces Codacy's Agentlinter engine over `CLAUDE.md`,
`AGENTS.md`, `.claude/rules/` and the command files, and fails on a diagnostic
missing from `.agentlinter-baseline.json` — fix it, or accept it with `make
agentlinter-update`. `.claude/rules/agentlinter-baseline.md` covers the
baseline and its justifications.

## Adding a New Command

1. Use `/new-command` — it orchestrates the RED → GREEN → REFACTOR → adversarial-review → validate → pr-review cycle for a command file.
2. Create `skills/nitpicker/commands/<name>.md` (short kebab-case name, 2.0 vocabulary — no `-auditor` suffixes).
3. Add its row to the `## Commands` table in `skills/nitpicker/SKILL.md`, the Routing Guide in `.claude/skills/skills/SKILL.md`, and the command table in `README.md`. Update `.github/copilot-instructions.md` only if the new command changes its rules (it deliberately carries no command table).
4. `make check` must pass (the validator enforces table ↔ file sync).
5. Commit with `feat: add /nitpicker <name> command` (minor bump via release-please).

## Conventions

Skill and command writing style, lifecycle, and repo conventions live in
`.claude/rules/`.

How much of each rule is machine-enforced varies: several are gated only in
part, and some not at all. Each rule states its own enforcement. Read that
statement in the rule itself rather than assuming a rule here is gated end to
end.

- `skill-format.md`
- `skill-style.md`
- `counts-in-prose.md` (author discipline for counts; the neighbouring
  reference drift — stale paths, dead anchors, stale dates, placeholders — is
  gated by `check-rules-anatomy.py`)
- `instruction-budget.md` (gated by `check-agent-instructions.py`: every file a
  session loads each turn draws on one shared budget; the rule file names the
  command that reports the current total)
- `skill-lifecycle.md` (agent discipline; no gate)
- `skill-official-best-practices.md`
- `use-uv-runner.md`
- `github-actions-security.md`
- `use-context-mode.md`
- `commit-gate-integrity.md`
- `commit-types.md` (author discipline; the CI `commit-lint` job gates only the
  CI-only-diff-with-a-breaking-marker case)
- `write-surgical-code.md` (agent discipline; no gate)
- `snapshot-before-mutating.md` (partly gated: the hook covers direct Bash and
  context-mode shell calls only, not a `git checkout` inside a script)
- `vendored-skills.md`
- Path-scoped, loaded with the files they govern: `mcp-stale-server.md`,
  `suppression-markers.md`, `agentlinter-baseline.md`, `version-bumps.md`,
  `enforcement-surface-owner.md`, `hooks-fail-closed.md`, `pr-fetchers.md`

## Plugin Metadata

| File                              | Purpose                                  |
| --------------------------------- | ---------------------------------------- |
| `.claude-plugin/plugin.json`      | Plugin name, version, author, keywords   |
| `.claude-plugin/marketplace.json` | Marketplace listing (used by `/plugins`) |

The version is recorded in `package.json`, `.claude-plugin/plugin.json`,
`.claude-plugin/marketplace.json`, `.release-please-manifest.json` and
`pyproject.toml`, and `scripts/check-version-sync.py` fails the build when
they disagree; `uv.lock` holds one more copy.
`.claude/rules/version-bumps.md` loads with those files and covers both bump
paths and resyncing the lockfile.

## Versioning

[Semantic Versioning](https://semver.org/) with [release-please](https://github.com/googleapis/release-please):

| Prefix                                | Effect                                      |
| ------------------------------------- | ------------------------------------------- |
| `feat:`                               | Minor bump (new command or feature)         |
| `fix:`                                | Patch bump (command improvement or bug fix) |
| `feat!:` / `BREAKING CHANGE:` footer  | Major bump                                  |
| `chore:`, `docs:`, `refactor:`, `ci:` | No bump                                     |

Merge to `main` → release-please opens a Release PR → merging it creates the GitHub Release and tag.

## Configuration

`.claude/settings.local.json` — local settings; gitignored.

`.claude/settings.json` — shared PostToolUse hooks on every Write/Edit:

- `validate-skill-hook.py` — validates SKILL.md structure on any edited SKILL.md or `commands/*.md` file
- `validate-json-hook.py` — validates JSON syntax on any edited `.json` file
- `check-version-sync-hook.py` — warns when a version file edit desyncs the version manifests
- `ruff-hook.py` — auto-fixes and lints any edited `.py` file
- `validate-audit-findings-hook.py` — validates files under `docs/audit/findings/` and regenerates `INDEX.md`
- `validate-rules-hook.py` — validates any edited `.claude/rules/*.md` file (`validate-rules.py` + `check-rules-anatomy.py`)
- `validate-evals-hook.py` — validates the eval set of any edited `skills/*/evals/*.json`
- `count-in-prose-hook.py` — reports a spelled-out count of a growing set in written Markdown or docstrings; feedback via exit 2, not a gate (`.claude/rules/counts-in-prose.md`)

When a validator behind one of these hooks is missing, hangs or cannot start,
the hook exits 1 with a line naming what did not run and the command to run by
hand, rather than exiting 0 in silence: exit 1 is non-blocking, so the edit
stands and the skip is visible. CI stays the binding gate.

Plus a shell PostToolUse hook (Bash and the context-mode shell tools),
`post-bash-revalidate.py`: an edit made through a shell (`sed -i`, redirection,
`git mv`) is invisible to the Write/Edit matchers, so this one re-runs the whole-tree gates when
`git status` shows a governed path dirty. Ignored entries count only inside the
findings store, so a clean tree costs one `git status`.

Plus the **PreToolUse** hooks below, which can *block* a tool call before it
runs — the most behaviour-changing entries in the file. `.claude/settings.json`
holds the authoritative list; `tests/test_settings.py` fails when one of them is
configured and unnamed here:

- matcher `Bash` and the context-mode shell tools (`ctx_execute`,
  `ctx_execute_file`, `ctx_batch_execute`) — `deny-agents-path-hook.py`, which
  blocks a shell command whose
  text names `.claude/agents/` **or a full protected agent filename** —
  literally, quoted, escaped, variable-built, or glob-spelled (the
  `permissions.deny` block binds file tools only, not Bash — it names `Read`,
  `Edit` **and** `Write` rules. Claude Code's permissions docs state that `Edit`
  rules apply to every built-in tool that edits files, so the `Write` entries
  are redundant belt and braces, kept because they cost nothing.
  `tests/test_settings.py` pins the exact list). So
  `find . -name release-readiness-reviewer.md -exec cat {} +` is blocked too.
  It raises the cost of reaching that tree; it does not close it. The guard
  matches tokens, so a command that locates the files by **content** rather than
  by path or name (`git ls-files | grep review | xargs cat`) carries neither
  token and passes — the one token it does carry, `review`, is a nitpicker
  command name that appears in ordinary commands constantly, so matching it
  would block routine work. Treat `.github/CODEOWNERS` plus branch protection as
  the binding control, not this hook. The same hook also blocks a shell
  **write** to any `PROTECTED_WRITE` path — `scripts/hooks/`,
  `.claude/settings.json`, `.claude/settings.local.json`,
  `.claude/skills/graphify/.graphify_version` — and refuses context-mode code in
  another language that names one of them or the agents tree, where reading
  stays allowed — so the enforcement surface cannot be edited around via
  `sed -i` or a redirect. `.claude/rules/enforcement-surface-owner.md` says who
  makes those edits.
- matcher `Bash` and the context-mode shell tools — `deny-unsafe-git-hook.py`,
  which blocks `git` with `--no-verify` or `-n` (stacked clusters and the
  abbreviations git accepts included), a `-c core.hooksPath=`, `--config-env`
  (either form) or `GIT_CONFIG_*` override that disables the repository's hooks,
  a `git config` write to `core.hooksPath` or `alias.*`, an alias whose body
  resolves to any of those (a `!` shell body included, and aliases already in
  git config judged by their body), a pre-commit skip variable on `git commit`
  (`.claude/rules/commit-gate-integrity.md` names them), `pre-commit uninstall`,
  a `git add` whose pathspec reaches the whole tree
  however it is spelled, and a push to a protected branch. It reads a command
  nested in `$(...)`, backticks or a subshell as its own stage, and judges the
  command behind a wrapper (`env`, `sudo`, `xargs`, …) as well as the wrapper.
  Per `.claude/rules/commit-gate-integrity.md` the pre-commit validators are not
  optional; commit without the flag and fix what fails. That rule also states
  what remains open — the guard matches command text, so a request carrying no
  `git` token at all (a shell function shadowing it, a script, `eval` on a
  runtime-built string) passes it.
- matcher `Bash` — `guard-ctx-ok-hook.py`, which validates the `# ctx-ok`
  escape hatch from `.claude/rules/use-context-mode.md` and denies it on any
  verb outside its allowlist, including every read verb. Fails closed on an
  unrecognised verb, so a denial usually means route the command through
  context-mode instead — not that the marker was spelled wrong.
- matcher `Bash` and the context-mode shell tools —
  `ask-destructive-restore-hook.py`, which asks before a `git checkout` of paths
  (with or without `--`) or a `git restore` that would discard uncommitted
  tracked changes, treating pathspec magic and globs as covering every dirty
  path. See `.claude/rules/snapshot-before-mutating.md`: snapshot with `cp`
  instead.
- matcher `Bash` — `graphify hook-guard search`
- matcher `Read|Glob` — `graphify hook-guard read`
- matcher `mcp__(plugin_…_)?nitpicker__np_(new_finding|resolve_finding|write_index|process_sarif)`
  — `deny-stale-mcp-write-hook.py`, which denies a findings-store or SARIF MCP
  write while `git status -- skills/nitpicker/scripts` is non-empty or
  unreadable, and names the CLI command to use instead
  (`.claude/rules/mcp-stale-server.md`).
- matcher the context-mode shell tools only — `deny-unguarded-cd-hook.py`,
  which denies a script that writes (a git write, a file-changing command,
  `sed -i`, a redirect into a file) after a `cd` or `pushd` that can fail. A
  `cd` counts as guarded by `|| …`, an `&&` chain reaching the write, or an
  earlier `set -e`. Bash is out of scope, since its working directory is the
  project; `cd x || true` still counts as guarded.

Every repo guard's registered command maps any exit other than 0 or 2 to 2, so a
guard that fails to run denies (`.claude/rules/hooks-fail-closed.md`).

Each graphify guard opens `command -v graphify >/dev/null || exit 0`, so on a
clone without graphify installed it exits 0 and is a no-op. When graphify is on
the executable search path (`$PATH`), the guard's own exit code propagates and
can block the call.

Between those two it **pins the binary**: it compares `graphify --version`
against `.claude/skills/graphify/.graphify_version` and exits 2 on a mismatch,
telling the agent to ask the owner. The pin file is on `permissions.deny` and
`PROTECTED_WRITE`, so an agent cannot re-pin it.
Without that, a different or compromised `graphify` earlier on `$PATH` silently
takes over the permit/deny decision for every file read, glob and shell command
in the session — the one executing component the vendored-skill trust model left
unbound. An **empty or missing** pin file denies rather than passes: treating an
unreadable pin as "no constraint" would make deleting one file disable the check
in silence. The ceiling is worth knowing — a version string is self-reported, so
this raises the cost of substitution and makes an accidental mismatch visible;
it is not attestation.

Plus a Stop hook, `stop-reminder.py`, which reminds about pending skill files
before Claude hands back control. Its scope is the union of the git index
(`git diff --cached`), the working tree (`git diff`) and the untracked set
(`git ls-files --others`), so **unstaged** and brand-new files count too.

It hands the reminder to the agent as `additionalContext` on exit 0
(`_hooklib.stop_feedback`), the form the hooks reference recommends for a hook
giving guidance: it continues the conversation as exit 2 would, but shows no
hook-error notification. A `stop_hook_active` guard keeps the reminder from
re-firing on the continuation it causes. That is one stop cycle, not one session:
the reminder repeats once per turn for as long as skill edits stay
uncommitted. That is the observed behaviour, not a broken guard.

A second Stop hook, `command-closure-reminder.py`, reads the session transcript
and reminds when a command loaded through `np_read_command` has process steps,
seeded with `np_task_create`, still `pending` or `in_progress` — or none seeded.
It reminds rather than blocks, under the same once-per-cycle guard: a turn ending
is not a command ending, since a run legitimately waits on the user and on
background work across turns. It sees only runs tracked with the `np_task_*`
tools, and checks that steps were closed, not that their work was done.

Every hook resolves the repo root as `CLAUDE_PROJECT_DIR` → `REPO_ROOT` → the computed parent of `scripts/hooks/`, in that order. `CLAUDE_PROJECT_DIR` is set by Claude Code; set `REPO_ROOT` only when running a hook manually outside Claude Code against a non-default tree.

That root picks the tree being validated and the working directory, not the
code doing the validating. The validators a hook runs, and the findings store
location, come from the checkout the hook ships in (`SHIPPED_ROOT` in
`_hooklib.py`). Pointing the environment at another checkout changes what is
checked, not what checks it.

`.claude/skills/nitpicker` is a symlink to `../../skills/nitpicker` so Claude Code discovers the shipped public skill alongside the internal dev skills.

## graphify

This project has a knowledge graph at graphify-out/ with god nodes, community structure, and cross-file relationships.

Rules:

- For codebase questions, first run `graphify query "<question>"` when graphify-out/graph.json exists. Use `graphify path "<A>" "<B>"` for relationships and `graphify explain "<concept>"` for focused concepts. These return a scoped subgraph, usually much smaller than GRAPH_REPORT.md or raw grep output.
- If graphify-out/wiki/index.md exists, use it for broad navigation instead of raw source browsing.
- Read graphify-out/GRAPH_REPORT.md only for broad architecture review or when query/path/explain do not surface enough context.
- After modifying code, run `graphify update .` to keep the graph current (AST-only, no API cost).