Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .claude-plugin/marketplace.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
"name": "claude-code-toolkit",
"source": "./",
"description": "Skills: scaffold-mcp (generate a tested MCP server), verify-mcp (check an MCP is green + well-formed with a health report), public-ready (audit + publish a repo), coverage-audit (audit test coverage for negative space, with a completeness validator), handoff (write a PICKUP handoff), design-note (write a structured design note), orchestrate (recommend a good use of subagents), claude-md-audit (trim a CLAUDE.md and move reference material to on-demand skills), memory-audit (keep auto-memory's MEMORY.md under the load cutoff), note (frictionless auto-categorized note-to-self), failure-scan (review a diff against a failure-mode catalog). Plus hooks: failure-mode (auto-flags risky patterns, e.g. unpaginated DynamoDB scans), context-alert (warns when session context crosses a threshold and offers a handoff checkpoint), subagent-nudge (flags parallelizable tasks and offers an orchestration plan), claude-md-curator (flags a CLAUDE.md growing past its budget and offers to move reference to skills), memory-curator (flags MEMORY.md crossing the auto-memory load cutoff), and coverage-nudge (flags source changes with no test changes and offers a coverage audit).",
"version": "0.12.0",
"version": "0.13.0",
"license": "MIT",
"keywords": [
"mcp",
Expand Down
2 changes: 1 addition & 1 deletion .claude-plugin/plugin.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
"name": "claude-code-toolkit",
"displayName": "Claude Code Toolkit",
"description": "Skills to scaffold MCP servers, verify an MCP is green and well-formed, audit repos for public release, audit test coverage for negative space, write session handoffs, write structured design notes, recommend a good use of subagents, audit a CLAUDE.md for bloat, audit auto memory for the load-cutoff, capture quick notes-to-self, and review a diff against a failure-mode catalog — plus hooks that auto-flag risky patterns, alert when session context crosses a threshold (offering a handoff checkpoint), nudge when a task looks parallelizable (offering an orchestration plan), nudge when a CLAUDE.md grows past its budget (offering to move reference material to skills), nudge when MEMORY.md crosses the auto-memory load cutoff, and nudge when source changes outpace test changes (offering a coverage audit).",
"version": "0.12.0",
"version": "0.13.0",
"author": {
"name": "Megan Schott"
},
Expand Down
24 changes: 16 additions & 8 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -195,17 +195,25 @@ reference).**
`~/.claude/CLAUDE.md` / secret-or-local→ `CLAUDE.local.md`). Cheapest bloat to
remove is the bloat that never lands. **Budget** — when the file exceeds
`CLAUDE_MD_LINE_BUDGET` (default 200), it nudges a full audit (debounced once
per session). Silenceable with `CLAUDE_MD_AUDIT_DISABLED`; never auto-edits.
per session). **Leak guard** — when `CLAUDE.local.md` is edited but isn't
gitignored, it warns (that file holds personal/secret content and would be
committed; `git check-ignore`, fails quiet outside a repo). Silenceable with
`CLAUDE_MD_AUDIT_DISABLED`; never auto-edits.
- **`/claude-md-audit`** is the judgment half: reads the whole file (+
`CLAUDE.archive.md` if present) and reports per-section verdicts on concision,
currency (stale facts vs the repo), usefulness, redundancy (incl. whether a new
addition is already covered), scope, and length. On approval it routes each
section: **keep / condense / extract-to-skill** (the primary release valve) **/
move-to-nested-CLAUDE.md** (area-specific; `.claude/rules` for path-scoped) **/
re-scope** (personal or secret content to `~/.claude/CLAUDE.md` or
`CLAUDE.local.md`) **/ archive** (stale → `CLAUDE.archive.md`, not auto-loaded)
**/ remove**. It never splits via `@import` (those load eagerly). Pairs with the
`context-doc-bloat` + `stated-not-derived-doc-facts` catalog entries.
addition is already covered), scope, and length. It also runs **cross-file
checks** over the whole loaded set: **contradictions** between CLAUDE.md /
nested / `.claude/rules` (which make Claude pick arbitrarily), and **dead
`.claude/rules` globs** whose `paths:` match nothing (so the rule silently
never loads). On approval it routes each section: **keep / condense /
extract-to-skill** (the primary release valve) **/ move-to-nested-CLAUDE.md**
(area-specific; `.claude/rules` for path-scoped) **/ re-scope** (personal or
secret content to `~/.claude/CLAUDE.md` or `CLAUDE.local.md`) **/ archive**
(stale → `CLAUDE.archive.md`) **/ remove**. It never splits via `@import`
(those load eagerly). Pairs with the `context-doc-bloat`,
`stated-not-derived-doc-facts`, `conflicting-instructions`, and
`dead-rule-scope` catalog entries.

`claude-md-curator/test_hook.py` keeps the triggers honest — any net addition
and over-budget fire; pure tweaks (net-0), non-targets, and the archive file
Expand Down
55 changes: 48 additions & 7 deletions claude-md-curator/hook.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,9 @@
(default 200), nudge a full /claude-md-audit (triage: keep / extract to a
skill / archive). Debounced once per session per file so it doesn't nag —
and so the skill's own curating edits don't re-trigger it.
• Leak guard: when CLAUDE.local.md is edited but is NOT gitignored, warn — it
holds personal/secret content and will otherwise be committed. Once per
session; uses `git check-ignore`, fails quiet outside a repo.

The principle it enforces: CLAUDE.md is the index (always-relevant directives);
skills are the chapters (on-demand reference).
Expand Down Expand Up @@ -99,24 +102,50 @@ def _state_path(session_id):
return os.path.join(tempfile.gettempdir(), f"claude-md-curator-{session_id}.json")


def _budget_already_fired(session_id, path):
"""Once per session per file for the over-budget audit nudge."""
def _fired_once(session_id, path, key):
"""Once per session per (key, file). `key` namespaces independent debounces
(e.g. over-budget vs gitignore warning) in one state file without clobbering
each other."""
state = _state_path(session_id)
fired = []
data = {}
if os.path.exists(state):
try:
with open(state, encoding="utf-8") as f:
fired = json.load(f).get("budget_fired", [])
data = json.load(f)
except Exception:
fired = []
data = {}
fired = data.get(key, [])
if path in fired:
return True
fired.append(path)
data[key] = fired
with open(state, "w", encoding="utf-8") as f:
json.dump({"budget_fired": fired}, f)
json.dump(data, f)
return False


def _local_not_gitignored(path):
"""True if a CLAUDE.local.md is NOT gitignored (so personal/secret content
risks being committed). Uses `git check-ignore`; fail toward quiet — if git
is absent, it's not a repo, or anything errors, return False (no warning)."""
import subprocess
d = os.path.dirname(os.path.abspath(path))
try:
inside = subprocess.run(
["git", "-C", d, "rev-parse", "--is-inside-work-tree"],
capture_output=True, text=True, timeout=5,
)
if inside.returncode != 0 or inside.stdout.strip() != "true":
return False # not a git repo -> nothing to commit into, stay quiet
ignored = subprocess.run(
["git", "-C", d, "check-ignore", "-q", path],
capture_output=True, timeout=5,
)
return ignored.returncode == 1 # 0=ignored, 1=not ignored, other=error
except Exception:
return False


def main():
if os.environ.get("CLAUDE_MD_AUDIT_DISABLED"):
sys.exit(0) # clean opt-out without disabling the whole plugin
Expand Down Expand Up @@ -157,14 +186,26 @@ def main():
"isn't already covered above."
)
files.append(name)
if over and not _budget_already_fired(session_id, path):
if over and not _fired_once(session_id, path, "budget_fired"):
notes.append(
f"{name}: now {lines} lines, over the {budget}-line budget. "
"Run /claude-md-audit to triage (keep & tighten / extract to a "
"skill / archive stale)."
)
if name not in files:
files.append(name)
# CLAUDE.local.md holds personal/secret content — it MUST be gitignored
# or it gets committed. Check once per session per file (status is stable).
if (name == "CLAUDE.local.md"
and _local_not_gitignored(path)
and not _fired_once(session_id, path, "local_gitignore_fired")):
notes.append(
f"{name} is NOT gitignored — it's meant for personal/secret "
"content (sandbox URLs, test data) and will be committed as-is. "
"Add it to .gitignore before it leaks into source control."
)
if name not in files:
files.append(name)

if not notes:
sys.exit(0)
Expand Down
29 changes: 29 additions & 0 deletions claude-md-curator/test_hook.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
"""
import json
import os
import shutil
import subprocess
import sys
import tempfile
Expand Down Expand Up @@ -152,6 +153,34 @@ def check(name, cond):
CLAUDE_MD_AUDIT_DISABLED="1")
check("CLAUDE_MD_AUDIT_DISABLED -> silent no-op", rc == 0 and not out)

# CLAUDE.local.md leak guard (needs git). Net-0 + under budget isolates
# the gitignore warning from the other triggers.
if shutil.which("git"):
repo = os.path.join(d, "gitrepo")
os.makedirs(repo, exist_ok=True)
subprocess.run(["git", "init", "-q", repo], check=False)
local = os.path.join(repo, "CLAUDE.local.md")
write_file(local, 20)
rc, out = run("Edit", local, old=block(3), new=block(3), session="s_leak1")
ok = (rc == 0 and out
and "gitignored" in json.loads(out)["hookSpecificOutput"]["additionalContext"].lower())
check("CLAUDE.local.md not gitignored -> leak warning", bool(ok))

with open(os.path.join(repo, ".gitignore"), "w", encoding="utf-8") as f:
f.write("CLAUDE.local.md\n")
rc, out = run("Edit", local, old=block(3), new=block(3), session="s_leak2")
check("CLAUDE.local.md gitignored -> silent", rc == 0 and not out)

# Outside any git repo -> fail quiet (no leak warning).
bare = os.path.join(d, "norepo")
os.makedirs(bare, exist_ok=True)
barelocal = os.path.join(bare, "CLAUDE.local.md")
write_file(barelocal, 20)
rc, out = run("Edit", barelocal, old=block(3), new=block(3), session="s_leak3")
check("CLAUDE.local.md outside a repo -> silent", rc == 0 and not out)
else:
print(" SKIP leak-guard tests (git not available)")

# Fail-safes.
rc, out = run_raw("not json at all")
check("garbage stdin -> fail-safe silent", rc == 0 and not out)
Expand Down
34 changes: 34 additions & 0 deletions failure-modes/catalog.md
Original file line number Diff line number Diff line change
Expand Up @@ -219,3 +219,37 @@ Detectability tiers:
`/memory-audit` skill + `memory-curator` hook enforce this.
- **Origin:** the auto-memory twin of context-doc-bloat — same index/chapters
cure, but MEMORY.md *truncates* at load where CLAUDE.md only dilutes.

## conflicting-instructions: Contradictory context instructions
- **Detectability:** judgment
- **Smell:** Two instructions that share the session's context disagree — root
`CLAUDE.md` vs a nested one, vs `~/.claude/CLAUDE.md`, vs a `.claude/rules/`
file (e.g. "use pnpm" vs "use npm"; two different test commands). All are
concatenated, so Claude **picks one arbitrarily** — a silent coin-flip with no
error, and the loser's intent just evaporates.
- **Signature:** none — behavioral, across files. Surfaces only by reading the
whole loaded set together; a per-file check can't see it.
- **Verify:** Do any two loaded instructions give different answers to the same
question (tooling, command, style, where files go)? Would following both be
impossible?
- **Fix pattern:** One source of truth per rule. Keep the most-specific or
most-correct statement; delete or correct the other. Periodically reconcile
across `CLAUDE.md` + nested + `.claude/rules/`. `/claude-md-audit` flags these.
- **Origin:** the docs warn it explicitly ("if two rules contradict, Claude may
pick one arbitrarily"); folded into `/claude-md-audit` as a cross-file check.

## dead-rule-scope: Path-scoped rule whose glob matches nothing
- **Detectability:** hook (flag-for-review) / judgment
- **Smell:** A `.claude/rules/*.md` file with `paths:` frontmatter whose glob no
longer matches any file (the code moved, was renamed, or the glob was wrong)
silently **never loads**. The rule looks active in the repo but is inert — an
expiring contract that quietly expired.
- **Signature:** a `paths:` glob in a rule file with zero matching files in the
repo. Greppable-ish (parse frontmatter, glob), but confirming intent is
judgment.
- **Verify:** For each path-scoped rule, do its globs still match real files? If
zero match, is that intentional (future path) or drift?
- **Fix pattern:** Fix the glob to match the moved/renamed path, or delete the
rule if its target is gone. Treat the glob as an expiring contract — it must
keep matching to keep working. `/claude-md-audit` checks this. Cousin of
stated-not-derived-doc-facts.
24 changes: 23 additions & 1 deletion skills/claude-md-audit/SKILL.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
---
name: claude-md-audit
description: Audit a CLAUDE.md (or AGENTS.md) for health — concision, currency, usefulness, redundancy, and length vs a line budget — then, on approval, tighten it and move reference material to on-demand skills (and stale guidance to CLAUDE.archive.md) so it stays the lean index it should be. Use when the curator hook nudges, when a context file crosses its line budget, when an addition might already be covered, or whenever a CLAUDE.md feels long or stale.
description: Audit a CLAUDE.md (or AGENTS.md) for health — concision, currency, usefulness, redundancy, scope, and length vs a line budget — plus cross-file checks (contradictions between CLAUDE.md/rules that make Claude pick arbitrarily, and dead .claude/rules path globs that silently never load). On approval, tighten it and move reference material to on-demand skills (stale guidance to CLAUDE.archive.md). Use when the curator hook nudges, when a context file crosses its line budget, when an addition might already be covered, when instructions seem to conflict, or whenever a CLAUDE.md feels long or stale.
---

# /claude-md-audit
Expand All @@ -25,6 +25,12 @@ curator hook passed *what was just added*, evaluate that block first.
State the file and its **line count vs the budget** (`CLAUDE_MD_LINE_BUDGET`,
default 200) up front.

For the cross-file checks in step 2.5, also enumerate the *other* instruction
sources that share this session's context: parent-dir and nested `CLAUDE.md`
(loaded for the working tree), `~/.claude/CLAUDE.md`, and `.claude/rules/*.md`
(plus `~/.claude/rules/`). You don't need to deeply audit each, but you need
their content to spot contradictions and dead rules.

## 2. Evaluate every section against five lenses

For each section / block:
Expand All @@ -48,6 +54,22 @@ For each section / block:
scope (e.g. a personal preference or a secret in the shared project file).
- **Bucket** — for anything not staying as-is, which destination (next step)?

## 2.5 Cross-file checks (the whole loaded set, not just one file)

Because every CLAUDE.md / rule in the hierarchy is concatenated into context,
two more silent failures live *between* files:

- **Contradictions.** If two instructions conflict (e.g. root says "use pnpm,"
a nested file says "use npm"; or two files give different test commands),
Claude picks one arbitrarily — a silent coin-flip. Compare the loaded set and
flag every conflicting or divergent-duplicate pair, with a recommended
resolution (usually: keep the most-specific/most-correct, delete the other).
See the `conflicting-instructions` catalog entry.
- **Dead `.claude/rules/` globs.** A rule whose `paths:` frontmatter glob matches
*no* file in the repo never loads — a silently inert rule (an expiring
contract). For each path-scoped rule, check its globs still match something;
flag the ones that don't (the glob drifted, or the code moved/was renamed).

## 3. Triage each demotable section

Each section that isn't staying as-is goes to one of these destinations:
Expand Down