claude-md-doctor
Give this repo's CLAUDE.md / AGENTS.md a checkup — size vitals vs official guidance, dead references, dead commands, stale claims — then backtest every rule against the repo's own Claude Code session history to see which rules were actually followed, ignored, or never used, and p
Install
npx skills add https://github.com/agent-clinic/claude-md-doctor/tree/main/skills/claude-md-doctor
claude plugin marketplace add https://llmmart.ai/marketplace.json && claude plugin install agent-clinic-claude-md-doctor@llmmart
git clone https://github.com/agent-clinic/claude-md-doctor.git
The skills CLI installs just this skill, for any of its supported agents. Claude Code installs the whole agent-clinic/claude-md-doctor collection as a plugin from our marketplace. Git is the plain clone.
Skill manifest
CLAUDE.md Doctor — exam procedure
You are running a checkup on this repository's agent-instruction files. The deterministic work lives in scripts; your job is the judgment between them. Do not re-derive what a script already measured, and do not skip a stage — the report verifies the work-state manifest and will disclose skipped stages.
Definitions used below:
SCRIPTS=${CLAUDE_SKILL_DIR}/scripts— Claude Code substitutes${CLAUDE_SKILL_DIR}with this skill's own directory at run time, so the scripts resolve no matter the working directory or how the skill was installed. Use it literally; do not try to locate the skill yourself.REPO= the repository to examine (the argument if one was given, else the current working directory).WORK=REPO/.claude-md-doctor/work(scripts default to this; pass--workto relocate, e.g. into a scratch directory to avoid writing in the user's repo — prefer that when the repo is not yours to dirty).
Stage 0 — preflight
Run python3 --version. If python3 is missing, stop and tell the user this
skill needs Python 3.9+ (stdlib only, nothing to install).
Stage 1 — intake
python3 SCRIPTS/intake.py --repo REPO --work WORK
Then read WORK/intake.json (it is small). Note for later judgment:
- Is the project CLAUDE.md a pointer (
is_pointer) to AGENTS.md? That is the healthy, officially-recommended pattern — the target is the patient. Never diagnose a pointer file as "too short." Checkpointer_style:symlinkandimportwork;bare-textis a broken pointer — a regular file containing justAGENTS.mdwithout@means Claude Code never loads the target. That is a critical diagnosis with a one-character fix (@), unless you are examining a raw-fetched copy where symlinks flatten to text. - Ancestor and user-scope files are context the session loads but the repo can't fix — mention them, don't prescribe changes to them unless asked.
- A file with scope
orphan-agentsmeans the repo has an AGENTS.md but no CLAUDE.md pointing at it — Claude Code loads nothing. That is a critical diagnosis with the official one-line fix (create a CLAUDE.md containing@AGENTS.md), and you should still run the full static exam on the AGENTS.md itself, since it becomes the patient the moment the pointer exists. - If NO memory files exist at all, do not stop with an empty report — switch
to Mode B (below): mine the session history and write the initial
chart. Only when there is no session history either does the exam end,
with the
/init-plus-aggressive-pruning prescription and a note saying why nothing else could run.
Stage 2 — vitals
python3 SCRIPTS/vitals.py --work WORK
Read WORK/vitals.json. The script measured; you interpret. Detector notes:
init_boilerplatemeans the file still opens with stock/initoutput — a generated-and-never-pruned marker.emphasis_per_100_linesmatters as density, not presence (sparse emphasis is officially endorsed).- High
dated_per_100_linessuggests session-log/changelog accretion. - Very low
imperative_ratioon a large file suggests narrative documentation rather than instructions — read a sample and judge; the arcan case (a CLAUDE.md containing a sabotage manual) is why this check exists. - Judge the aggregate surface, not only each file (
launch_loaded_combinedplus the file count): many individually-healthy files can still sum to a heavy standing context, and cross-file duplication or contradiction is invisible per-file. When the combined surface is the problem, prescribe the escalating ladder — consolidate duplicates, then a thin router/index over on-demand files, then a one-screen always-on invariants file with procedures moved to skills (citation idsurface-bloat).
Stage 3 — records check
python3 SCRIPTS/refcheck.py --work WORK
Read WORK/refcheck.json. Your judgment passes:
- Review the failures, don't parrot them. For each
missing/machine_specific/glob_emptyreference and eachmissingcommand, open the cited file:line and confirm it is a real reference (not prose that merely looks like a path — API endpoints, MIME types, git refs, and files the text describes as deleted are the common false positives). Record each false positive indismissed_refswith its reason: the report shows only confirmed findings and discloses dismissals in a collapsed note. - Extract checkable claims the scripts cannot: countable assertions in
the memory files ("3,540 tests across 374 files", "12 UI components",
"there is no ESLint config"). Verify the cheap ones with quick commands
(file counts, grep for configs). Do NOT run test suites or builds unless
the user asked. Record each as
verified/drifted/unverifiedwith a one-line detail —unverifiedis an honest answer for anything expensive.
Stage 4 — history backtest
Skip this stage only if intake found no session directory (sessions.dir
null) — and then say so in chat; the report's History section will state it.
4a — condense the transcripts
python3 SCRIPTS/sessions.py --work WORK
4b — decompose the memory files into a rulebook (your judgment)
Write WORK/rulebook.json (schema documented at the top of backtest.py).
Guidance:
- Decompose EVERY directive in the file — the rulebook is the complete
directive inventory, and the enforcement ladder's "N of M" is only honest
if M is the whole file. Only mechanically checkable rules get matchers:
bans and requirements visible in Bash commands or Edit/Write content, and
finish-ordering rules via
ordering. Judge-class and not-yet-mechanizable rules go in as classification-only entries (enforcement block, no matchers) — never force a regex onto a semantic rule. Informational content (facts, architecture, API semantics) stays OUT of the rulebook. - For edit/write events the matchable text is
PATH: <file_path>on the first line followed by the (truncated) new content — anchor path-based rules on^PATH: .*…and content rules on the body. - Write conservative regexes (prefer false negatives over false
positives), use
scope.paths/scope.exclude_pathsto confine file-scoped rules, and date each rule withintroducedfromgit log --follow --format=%aI -- <file>when the file's history makes that cheap — sessions that ended before a rule existed must not count against adherence. - Classify every rule's enforcement (the
enforcementblock — schema at the top ofbacktest.py). Split compound rules into clauses first; each clause classifies independently. The class is the cheapest reliable detector:hook(event-stream regex: bash/edit/path/tool-input/output gates, ordering, cadence — try the event-ordering and standing-invariant reframings BEFORE surrendering a rule to judge),linter/test(static analysis over artifacts: lint rules, discipline tests, import-graph boundaries — recordscope_kind: file|project), orjudge(only an LLM can score it). A rule even a judge couldn't score is not a rule — diagnose itvague. Detect existing enforcement: if the repo already has the test/lint/hook the prose describes, setcurrent_layerto it — that rule is a healthy pointer, never a prescription target.current_layermay also be an org-level rule platform (team-wide rulebooks with centralized detectors/telemetry) — the right home for cross-repo rules, judge-class auditing at scale, and staged warn→block rollouts that per-repo configs can't govern. Give every classified rule anecho_regexof its distinctive tokens (for proven-defiance detection) and anorigin(root/nested/rules — only non-root rules can be truly absent after compaction). Also judgeagainst_prior: true|falsein the enforcement block: would a frontier model do this by default WITHOUT the rule? A with-prior rule showing high compliance may be coincidence, not obedience (citation idharness-if) — flag it as a redundancy candidate in diagnosis rather than celebrating it as healthy. And when prescribing move-to-skill: that move is for procedures only — a constraint demoted into a skill description measurably loses precedence (project files outrank tool/skill descriptions).
Engine semantics you need (so you don't reverse-engineer them):
- "Opportunities" = matcher fires (violation+compliance+context hits) for regex rules, and mutated-session count for ordering rules. Zero can mean "rule never applied" OR "your scope is wrong" — for any zero-fire path-scoped rule, run one negative control (confirm the sessions contain no events under that scope at all) before calling it inert.
scope.pathsfilters only events that carry a file path; bash events pass a paths filter (they have no path) — for mixed bash+edit rules put path constraints into the regex (^PATH: …) if bash must be excluded.exclude_pathsandrepo_onlyDO apply to ordering-rule mutation counting.- Edit/Write matchable content is truncated to ~1200 chars of new content (bash commands ~600) — first-line rules are fine; end-of-file or size rules are not expressible as content regexes.
- Condensed sessions are a top-level JSON array of event objects.
- Read-before-edit ordering is NOT yet expressible (
ordering.requirematches bash commands only) — classify such rules as unmechanized hooks; don't torture a regex.
4c — run the engine
python3 SCRIPTS/backtest.py --work WORK
4d — sample-verify (MANDATORY — matchers have bugs)
Read WORK/backtest.json. For EVERY rule with fires — violation AND
compliance samples both — read the sample excerpts and confirm each is a
true positive. A matcher with any false
positive gets fixed in rulebook.json and the engine re-run — this loop is
cheap and it is the whole reason the results can be trusted. Only when every
sampled fire is confirmed, set "verified": true in backtest.json
(edit the file) — the report shows a "provisional" banner otherwise.
Mention is not use. The most common false positive is a session that
talks about a rule rather than breaking it: documenting the hazard,
grepping for offenders, writing the rule itself, quoting it in a commit
message or a retraction. A regex cannot tell discussion from violation, and
these land as defiance-proven — the most severe cause — because the rule
text is echoed right there. When a repo's own docs quote its rules, expect
this and check the excerpt for whether the event performed the banned
action or merely referred to it. Repos that document their own conventions
generate this heavily; drop those fires and tighten the matcher (anchor on
the action, exclude edits to the memory files and docs via
scope.exclude_paths).
Then record per-rule verdicts in diagnosis.json under rule_verdicts:
"rule_verdicts": {
"R1": {"verdict": "healthy|ignored|mixed|inert", "note": "one line of judgment"}
}
(That is the common subset — Stage 5's schema is canonical and adds
unmeasured|abandoned|undocumented; mined rules take undocumented.)
inert (zero opportunities in the window) is a finding, not a failure —
say what it means: the rule cost context in every session and never came up.
The engine also triages every violation by cause: defiance-proven (the
agent echoed the rule in its own text, then violated it — the reminder
already happened and lost), defiance (fresh context), dilution (late
turn / heavy context), absence-risk (non-root rule after a compaction
boundary). Read the causes before judging: they pick the medicine — proven
defiance justifies block-mode; dilution calls for slimming/path-scoping, not
cages; absence calls for re-injection hooks. Sanity-check the buckets while
sample-verifying (a "dilution" tag on a turn-2 violation means the occupancy
proxy misfired — say so). Ordering-rule caveat: verdicts are per-transcript —
in subagent/worktree workflows the required command may have run in a sibling
transcript. A conversation message claiming it ran ("verify green") is not
proof; note the claim in your verdict and check whether repo edits happened
after it (the obligation re-ripens).
4e — compile enforcement proposals
python3 SCRIPTS/compile.py --work WORK
This writes WORK/enforcement/ — a PROPOSALS.md dossier per rule, a generic
guard script, its per-rule config (warn-mode by default; defiance-proven
rules start at block), and a settings snippet. Never install any of it
yourself; never edit the user's .claude/settings.json. Goodhart caution
(citation id specbench): a visible pattern-gate can be satisfied without
honoring the rule — where a rule has a real outcome (tests pass, build
green), prefer a gate that runs the outcome over one that greps a pattern. Tell the user
where the proposals live and that they are review-then-arm.
4f — gap analysis: what the sessions dictate that the file never says
python3 SCRIPTS/mine.py --work WORK
Read WORK/candidates.json and compare the surviving groups against the
rulebook: a recurrent signal (correction cluster, failed→fixed command
pair, recurring user denial) that matches NO existing rule is a rule the
user keeps dictating by hand, session after session. Judge as in Mode B2
below — decline one-offs and stale groups (automode blocks are already
excluded by the miner). For each
accepted miss, add a diagnosis with state undocumented (severity warn,
evidence = 1–2 excerpts), and when there are enough to matter, write
WORK/chart.json with "mode": "gap" (schema at the top of generate.py)
and run python3 SCRIPTS/generate.py --work WORK to emit
PROPOSED-ADDITIONS.md. Mind the combined budget: proposed additions must
not push the surface past the size target this same exam just graded.
Stage 5 — diagnosis (your judgment, written to a file)
Write WORK/diagnosis.json:
{
"grade": "B",
"chief_complaint": "One sentence, doctor-voice, the single biggest issue.",
"history_note": "optional override for the History section",
"stale_claims": [
{"claim": "…", "file": "/abs/path", "line": 12,
"status": "verified|drifted|unverified", "detail": "…"}
],
"dismissed_refs": [
{"ref": "the exact ref string from refcheck.json", "line": 46,
"reason": "why it is a false positive (route not file, MIME type, described as deleted, …)"}
],
"rule_verdicts": {
"R1": {"verdict": "healthy|ignored|mixed|inert|unmeasured|abandoned|undocumented",
"note": "one line of judgment; 'abandoned' = the repo's own history contradicts the rule (e.g. git shows the team doing the banned thing routinely) even if sessions were inert"}
},
"diagnoses": [
{"state": "dead-ref|stale|vague|ignored|inert|redundant|contradictory|oversized|accretion|generated-unpruned|undocumented",
"severity": "critical|warn|info",
"title": "short name", "detail": "1–3 sentences, plain language",
"file": "/abs/path", "line": 46,
"evidence": ["short quoted lines or metric readouts"],
"citations": ["official-200"],
"prescription": "the concrete fix, imperative voice"}
],
"prescriptions": [
{"action": "repo-wide action", "rationale": "why", "citations": ["eth"]}
],
"followup": ["re-run cadence; transcript-retention advice; what to fix first"],
"share_note": "one quotable line for the public share card — dry doctor's wit backed by the findings. STRICT safety: no file paths, no rule text, no quotes from the repo, no session ids, nothing repo-identifying; aggregate truths only (e.g. 'The loudest rule was the broken one.'). Omit the field to use a deterministic fallback."
}
Rules for this stage:
- Every diagnosis needs evidence (a quoted line, a metric, a failed check)
and, where one exists, a citation id. List the valid ids and what each
source claims with
python3 SCRIPTS/report.py --list-citations— use only those ids, and only where the source actually supports the point. A check with no official or research backing is stated as a heuristic in itsdetail. - Severity honestly:
critical= the file lies to the agent (dead refs, drifted claims, contradictions) or content is being skipped (4 MiB);warn= costs context or reduces adherence (oversized, emphasis saturation, accretion);info= worth knowing. - Structure-only findings carry a caution: the one factorial study found
no structural effect in its tested range (citation id
mcmillan) — do not present size/position folklore as causal fact. Content findings (dead refs, drift) need no such hedge. - Grade rubric: A = no criticals and at most 2 warns; B = no criticals and 3 or more warns; C = 1–2 criticals; D = 3+ criticals; F = the file is actively misleading (mostly dead/drifted) or unloadable. A pointer-style CLAUDE.md with a healthy target grades on the target.
- Cannot-fix scopes (ancestor/user/managed files) may generate
infodiagnoses only. - Never mention this tool's version numbers in report content (diagnoses, notes, follow-ups, chief complaint). The renderer stamps the version in the report footer; content reads timelessly — a reader doesn't know or care what "v0.2" means.
- Pointer repos are usually cross-agent repos. When the patient is an
AGENTS.md reached via a pointer, it likely serves Cursor/Codex/Copilot too —
prescriptions that relocate content into Claude-only surfaces
(
.claude/rules/, skills, hooks) hide it from those agents. Still prescribe them when right, but state the trade-off in the prescription ("Claude-only; other agents reading AGENTS.md will lose this") and prefer in-file fixes for content every agent needs.
Stage 6 — report
python3 SCRIPTS/report.py --work WORK
Then generate the share-safe card and badge:
python3 SCRIPTS/card.py --work WORK
card.svg (postable checkup card) and claude-md-health.svg (README badge)
land next to report.html. Both are aggregate-only by construction — but eye
the card once anyway before telling the user it is safe to post. Offer the
badge snippet the script prints for their README.
Open or send the resulting report.html to the user, and summarize in chat:
grade, chief complaint, the top 3 findings, and the single highest-value
prescription. Tell the user where the report lives. If report.py printed an
INCOMPLETE warning, say which stage was missing and why.
Mode B — the chart-less patient (no memory file? mine one)
Route here when intake found NO memory files at all, or when the user asked
to generate a CLAUDE.md / hooks / lint suggestions from their history —
but if the repo already HAS a memory file, never run Mode B: run the normal
exam and satisfy the generate request through Stage 4f (gap analysis,
"mode": "gap" → PROPOSED-ADDITIONS.md), so the existing file stays the
patient and the intake framing ("no memory file exists") stays true.
The transcripts already contain the unwritten rulebook: corrections the
user keeps typing, commands that fail until the right one runs, facts
re-derived at every session start, tool calls the user rejects. Mode B
takes a history and writes the initial chart.
Run Stages 2 and 3 first anyway — on an empty surface they finish instantly, keep the manifest honest, and "0 tokens loaded every session" is the patient's baseline vital.
B1 — condense and mine
python3 SCRIPTS/sessions.py --work WORK
python3 SCRIPTS/mine.py --work WORK
candidates.json holds mechanically pre-filtered signals in five families
(corrections, failure_recovery, rediscovery, denials, preambles) plus a
startup-tax estimate. The lexical markers are calibrated to ~65–75%
precision — YOU are the judge pass; nothing in this file is a rule yet.
B2 — judge the candidates (your judgment)
Triage every entry:
- A rule states something durable the user would still endorse: repeated
corrections that converge ("use pnpm", "never push directly"),
failed→fixed pairs whose fix is systematic (wrong runner, wrong dir,
missing env var), recurring
user-rejected/permission-ruledenials. Write it as one imperative line. - A fact is repo knowledge the agent keeps re-deriving: build/test commands from rediscovery groups, layout/context from preamble clusters.
- Decline one-off taste, task-specific instructions, anything flagged
stale(the repo may have moved past it), and excerpts you cannot confidently generalize. (Auto-mode classifier blocks are already excluded by the miner — they appear only asmeta.automode_blocked; do not resurrect them.) Record notable declines with reasons — the report discloses them. - The recurrence gates already ran for the GROUPED families
(failure_recovery, rediscovery, denials, preambles). Corrections are
ungated — any single flagged message reaches you — so judge them hardest;
a one-off correction is only a rule if its content is plainly durable.
Then apply the meaning test to everything: would the user bet on this
line? When unsure, decline — a mined draft earns trust by being small.
And spot-check recall: the pre-filter misses bare factual corrections
without marker words (
meta.known_gaps); skim one or two condensed sessions' user texts if the yield looks thin.
B3 — validate mined rules through the backtest (receipts)
For each accepted rule that is mechanically checkable, write a standard
WORK/rulebook.json entry (schema at the top of backtest.py; set
"source": {"file": "mined-from-history", "line": 0}, classify its
enforcement, give it an echo_regex), then:
python3 SCRIPTS/backtest.py --work WORK
python3 SCRIPTS/compile.py --work WORK
The backtest counts are the rule's receipts — a mined rule whose matcher finds nothing in the very history that suggested it is a mining false positive: drop it. Sample-verify fires exactly as in Stage 4d. compile writes review-then-arm hook proposals for hook-class mined rules — born mechanized: the best CLAUDE.md line is the one a guard enforces.
B4 — write the chart and generate the draft
Write WORK/chart.json (schema at the top of generate.py): accepted facts
and rules with occurrences/sessions from mining or backtest, per-item
evidence excerpts, startup_tax (copy est_tokens/sessions from
candidates.json — the estimate is attributed to the recurring discovery
commands' own records and results, so quote it as exactly that, an
estimate), and your declined list. Then:
python3 SCRIPTS/generate.py --work WORK
This assembles PROPOSED-CLAUDE.md next to the report — receipts ride in
HTML comments, which Claude Code strips at load, so they cost the adopter
nothing. It exits nonzero if the draft breaks the official 200-line target:
the doctor does not prescribe the disease it diagnoses. Never copy the
draft into the repo yourself — adoption is the user's move.
B5 — diagnosis, report, card
Proceed to Stages 5 and 6 as usual. Mode B specifics:
chief_complaint: the absence plus its cost, with evidence ("No memory file exists; N sessions show M recurring rules dictated by hand").- Grade the gap, not the void: D when the history shows recurring unwritten rules being re-dictated or violated; C when the mined chart is thin. F stays reserved for actively misleading files — absence is not deception.
rule_verdictsfor backtested mined rules use verdictundocumented(they cannot be "ignored" — there was no file to ignore).- The report renders the Initial chart section from
chart.jsonand the card switches to intake stats automatically. Tell the user wherePROPOSED-CLAUDE.mdlives, that every line carries its receipt, and that hook proposals (if any) are review-then-arm.
Conduct
- Everything runs locally; never send file contents anywhere.
- Quote at most ~2 lines from any file in evidence.
- In a headless or background run, do not try to "open" the report — state its path (report.html lands in the work directory's PARENT, next to report.json) and summarize it.
Files (claude-md-doctor)
-
scripts
-
backtest.py 17.9 KB
#!/usr/bin/env python3 """Stage 4b — backtest: replay each rule's matchers over the condensed sessions. Input is a model-authored <work>/rulebook.json decomposing the memory files into mechanically checkable rules. The engine is deterministic; the model's jobs before and after are (a) writing good matchers and (b) sample-verifying every fire — matchers WILL have bugs, so no result counts until sampled. Rule schema (all regexes are Python, compiled case-sensitively unless the pattern itself opts out): { "rules": [{ "id": "R1", "text": "…", "source": {"file": "…", "line": 46}, "introduced": "2026-06-01T00:00:00Z", # optional (git-dated) "scope": {"events": ["edit","write"], # bash|edit|write|tool|assistant|user|tool_error "paths": ["src/studio/**"], # optional, fnmatch vs repo-relative path "exclude_paths": ["**/tokens.css"], # optional "repo_only": true}, # optional: ignore edits outside the repo "matchers": {"violation": "…", "compliance": "…", "context": "…"}, "ordering": {"require": "regex on bash commands", "desc": "run pnpm verify before finishing", "min_mutations": 1}, # ordering rules need no matchers "origin": "root", # root|nested|rules|conversation — # only non-root rules can be truly # absent post-compact "enforcement": { # v0.3 classification (model-authored) "class": "hook", # hook|linter|test|judge "subtype": "bash-gate", # e.g. bash-gate|edit-gate|stop-gate| # tool-input-gate|output-gate "scope_kind": "file", # file|project (linter class) "current_layer": "prose", # prose, or the existing enforcement # (hook|linter|test|ci) the prose points at "mechanism": "PreToolUse Bash regex", # named mechanism for the prescription "echo_regex": "pnpm verify|No exceptions" # distinctive tokens: an assistant-text } # match BEFORE a violation = proven defiance }] } Each violation is triaged by context state into a cause — defiance-proven (rule echoed in the agent's own words, then violated) > defiance (fresh context) > dilution (late turn / heavy context) > absence-risk (non-root rule after a compaction boundary) — and each rule gets an arming recommendation (reminder → warn-hook → block) derived from its cause mix. Occupancy is proxied by raw-transcript byte offsets; compaction boundaries come from the transcript's own markers. Usage: python3 backtest.py --work DIR Reads: <work>/rulebook.json, <work>/sessions/*.json Writes: <work>/backtest.json """ import argparse import fnmatch import os import re import shlex import sys sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) from _common import load_json, manifest_add, save_json SAMPLE_V, SAMPLE_C = 6, 3 EDIT_NAMES = {"Edit", "MultiEdit", "NotebookEdit"} DILUTION_TURN = 9 # violations past this turn lean dilution (SysBench/McMillan) DILUTION_BYTES = 400_000 # raw-transcript bytes since last compaction ≈ heavy context class SessionContext(object): """Per-session context-state features for cause triage.""" def __init__(self, events): self.compact_offs = [e.get("off", 0) for e in events if e["t"] == "compact"] self.assistant_texts = [(i, e.get("text", "")) for i, e in enumerate(events) if e["t"] == "assistant"] def epoch_start(self, off): prior = [c for c in self.compact_offs if c <= off] return max(prior) if prior else 0 def post_compact(self, off): return any(c <= off for c in self.compact_offs) def echoed_before(self, rx, idx): return bool(rx) and any(rx.search(t) for i, t in self.assistant_texts if i < idx and t) def cause_of(rule, rx_echo, ev, idx, ctx): off = ev.get("off", 0) if ctx.echoed_before(rx_echo, idx): return "defiance-proven" if rule.get("origin", "root") != "root" and ctx.post_compact(off): return "absence-risk" if ev.get("turn", 0) > DILUTION_TURN or \ (off - ctx.epoch_start(off)) > DILUTION_BYTES: return "dilution" return "defiance" def recommend_arming(st, enf): cls = (enf or {}).get("class") cur = (enf or {}).get("current_layer") or "prose" if cur != "prose": return "already enforced (%s) — prose is the pointer; verify it still runs" % cur if cls == "judge": return "judge-class: stays prose; audited by backtest, reliability ceiling applies" if cls not in ("hook", "linter", "test"): return "unclassified — classify before arming" if not st.get("mechanized", True): return "classified, not yet mechanized — write a matcher to backtest it" v, c = st["violations"], st["causes"] if v == 0: return ("healthy in window — enforcement optional" if st["compliances"] else "inert in window — no arming evidence either way") if c.get("defiance-proven"): return "BLOCK-ready: rule was echoed then violated — the reminder already happened and lost" half = max(1, v / 2.0) if c.get("defiance", 0) >= half: return "arm warn-mode now; graduate to block after a clean warn period" if c.get("dilution", 0) >= half: return "soft first: slim the file / path-scope to point of use / PostToolUse nudge" if c.get("absence-risk", 0) >= half: return "re-inject: SessionStart or PreCompact hook, or a path-scoped rule" return "mixed causes — warn-mode hook and re-triage next run" def event_kind(ev): if ev["t"] == "tool": name = ev.get("name") if name == "Bash": return "bash" if name in EDIT_NAMES: return "edit" if name == "Write": return "write" return "tool" return ev["t"] # user | assistant | tool_error def event_text(ev): kind = event_kind(ev) if kind == "bash": return ev.get("command", "") if kind in ("edit", "write"): return "PATH: %s\n%s" % (ev.get("file_path", ""), ev.get("new", "")) if kind == "tool": return "%s %s" % (ev.get("name", ""), " ".join(ev.get("input_keys", []))) return ev.get("text", "") def path_in_scope(ev, repo, scope): paths = scope.get("paths") excludes = scope.get("exclude_paths") or [] fp = ev.get("file_path") if scope.get("repo_only") and fp and repo \ and not fp.startswith(repo + os.sep): return False # edits outside the examined repo never count if fp: rel = os.path.relpath(fp, repo) if fp.startswith(repo + os.sep) else fp for pat in excludes: if fnmatch.fnmatch(rel, pat) or fnmatch.fnmatch(fp, pat): return False if paths: return any(fnmatch.fnmatch(rel, p) or fnmatch.fnmatch(fp, p) for p in paths) return True return not paths or event_kind(ev) == "bash" # pathless events pass unless path-scoped def depth_bucket(turn): if turn <= 3: return "early" return "mid" if turn <= 8 else "late" def compile_rule(rule): m = rule.get("matchers") or {} return {k: re.compile(m[k]) for k in ("violation", "compliance", "context") if m.get(k)} ENV_PREFIX_RE = re.compile(r"^(?:[A-Za-z_][A-Za-z0-9_]*=(?:\"[^\"]*\"|'[^']*'|\S+)\s+)+") def command_word(cmd): """First meaningful word of a shell command: split compound commands on && / ; / |, strip env-assignment prefixes, take the first real word.""" fallback = None for seg in re.split(r"&&|\|\||;|\|", cmd or ""): seg = ENV_PREFIX_RE.sub("", seg.strip()) try: words = shlex.split(seg) # quote-aware: `cd "/my repo" && ...` except ValueError: # unbalanced quotes — fall back words = seg.split() if not words or re.fullmatch(r"[A-Za-z_][A-Za-z0-9_]*=.*", words[0]): continue word = os.path.basename(words[0])[:24] # `cd <dir> &&` is navigation; label the segment that does the work. if word in ("cd", "pushd", "popd"): fallback = fallback or word continue return word return fallback or "?" def build_timeline(tool_seq, last_mut, last_mut_turn, mutations): """Run-length encode the session's tool sequence for the report's strip visualization, and summarize what ran after the last mutation.""" segments, after_counts = [], {} for kind, idx, word in tool_seq: after = idx > last_mut if segments and segments[-1]["k"] == kind and segments[-1]["after"] == after: segments[-1]["n"] += 1 else: segments.append({"k": kind, "n": 1, "after": after}) if after and kind == "bash" and word: after_counts[word] = after_counts.get(word, 0) + 1 if len(segments) > 60: # keep the strip drawable head, tail = segments[:30], segments[-29:] merged = {"k": "other", "n": sum(s["n"] for s in segments[30:-29]), "after": tail[0]["after"] if tail else False} segments = head + [merged] + tail return {"segments": segments, "edits": mutations, "last_edit_turn": last_mut_turn, "after_cmds": sorted(after_counts.items(), key=lambda kv: -kv[1])[:5]} def excerpt(text, match, span=90): s, e = match.start(), match.end() lo, hi = max(0, s - span), min(len(text), e + span) return ("…" if lo else "") + text[lo:hi].replace("\n", "⏎") + ("…" if hi < len(text) else "") def main(): ap = argparse.ArgumentParser() ap.add_argument("--work", required=True) args = ap.parse_args() rulebook = load_json(os.path.join(args.work, "rulebook.json")) intake = load_json(os.path.join(args.work, "intake.json")) or {} index = load_json(os.path.join(args.work, "sessions_index.json")) or {} if not rulebook or not rulebook.get("rules"): sys.exit("backtest: missing or empty rulebook.json (write it first — see SKILL.md stage 4)") repo = intake.get("repo", "") sess_dir = os.path.join(args.work, "sessions") # a "session" with zero tool calls is a stub in spirit (greeting-only or # caveat records) — replaying it inflates the coverage claim sessions = [s for s in index.get("sessions", []) if s.get("events") and s.get("tools", 0) > 0] stats = {} for rule in rulebook["rules"]: stats[rule["id"]] = { "text": rule.get("text", ""), "source": rule.get("source"), "opportunities": 0, "violations": 0, "compliances": 0, "pre_rule_sessions": 0, "sessions_with_activity": 0, "violations_by_depth": {"early": 0, "mid": 0, "late": 0}, "causes": {}, "enforcement": rule.get("enforcement"), "origin": rule.get("origin", "root"), "mechanized": bool((rule.get("matchers") or {}).get("violation") or (rule.get("matchers") or {}).get("compliance") or rule.get("ordering")), "samples": {"violations": [], "compliances": []}, } for sess in sessions: events = load_json(os.path.join(sess_dir, sess["id"] + ".json")) or [] ctx = SessionContext(events) for rule in rulebook["rules"]: st = stats[rule["id"]] echo_pat = (rule.get("enforcement") or {}).get("echo_regex") rx_echo = re.compile(echo_pat) if echo_pat else None def tally_cause(ev, idx): cause = cause_of(rule, rx_echo, ev, idx, ctx) st["causes"][cause] = st["causes"].get(cause, 0) + 1 return cause pre_rule = bool(rule.get("introduced")) and \ bool(sess.get("last_ts")) and sess["last_ts"] < rule["introduced"] if pre_rule: st["pre_rule_sessions"] += 1 rx = compile_rule(rule) scope = rule.get("scope") or {} kinds = set(scope.get("events") or ["bash", "edit", "write"]) active = False if rule.get("ordering"): o = rule["ordering"] req = re.compile(o["require"]) last_mut, last_mut_turn, req_after = -1, 0, False mutations = 0 tool_seq = [] # (kind, event index, command-word or None) for i, ev in enumerate(events): k = event_kind(ev) if k in ("edit", "write") and path_in_scope(ev, repo, scope): last_mut, mutations, req_after = i, mutations + 1, False last_mut_turn = ev.get("turn", 0) tool_seq.append(("edit", i, None)) elif k == "bash": cmd = ev.get("command", "") tool_seq.append(("bash", i, command_word(cmd))) if req.search(cmd) and i > last_mut: req_after = True elif k in ("write", "edit", "tool"): tool_seq.append(("other", i, None)) if mutations >= o.get("min_mutations", 1) and not pre_rule: st["opportunities"] += 1 active = True viz = build_timeline(tool_seq, last_mut, last_mut_turn, mutations) if req_after: st["compliances"] += 1 if len(st["samples"]["compliances"]) < SAMPLE_C: st["samples"]["compliances"].append( {"session": sess["id"], "ok": True, "viz": viz, "note": "%d file edits; required command ran afterwards" % mutations}) else: st["violations"] += 1 st["violations_by_depth"]["late"] += 1 # cause is judged where the obligation ripened — the # last mutation — not at session end ripen = last_mut if last_mut >= 0 else len(events) - 1 cause = tally_cause(events[ripen], len(events) - 1) if len(st["samples"]["violations"]) < SAMPLE_V: st["samples"]["violations"].append( {"session": sess["id"], "turn": events[-1].get("turn", 0), "viz": viz, "cause": cause, "note": "session ended after %d file edits without: %s" % (mutations, o.get("desc", o["require"]))}) else: for i, ev in enumerate(events): if event_kind(ev) not in kinds or not path_in_scope(ev, repo, scope): continue text = event_text(ev) vm = rx.get("violation").search(text) if rx.get("violation") else None cm = rx.get("compliance").search(text) if rx.get("compliance") else None xm = rx.get("context").search(text) if rx.get("context") else None if not (vm or cm or xm) or pre_rule: continue st["opportunities"] += 1 active = True if vm and not cm: st["violations"] += 1 st["violations_by_depth"][depth_bucket(ev.get("turn", 0))] += 1 cause = tally_cause(ev, i) if len(st["samples"]["violations"]) < SAMPLE_V: st["samples"]["violations"].append( {"session": sess["id"], "turn": ev.get("turn", 0), "event": event_kind(ev), "cause": cause, "file": ev.get("file_path"), "excerpt": excerpt(text, vm)}) elif cm: st["compliances"] += 1 if len(st["samples"]["compliances"]) < SAMPLE_C: st["samples"]["compliances"].append( {"session": sess["id"], "turn": ev.get("turn", 0), "excerpt": excerpt(text, cm)}) if active: st["sessions_with_activity"] += 1 for rid, st in stats.items(): st["arming"] = recommend_arming(st, st["enforcement"]) out = { "window": { "sessions_replayed": len(sessions), "stub_sessions_skipped": len(index.get("sessions", [])) - len(sessions), "total_events": sum(s.get("events", 0) for s in sessions), "total_tool_calls": sum(s.get("tools", 0) for s in sessions), "from": min((s.get("first_ts") or "" for s in sessions), default=None), "to": max((s.get("last_ts") or "" for s in sessions), default=None), "machine_note": "this machine's transcripts only; bounded by cleanupPeriodDays", }, "per_rule": stats, "verified": False, # flipped by the model after the sample-verification pass } save_json(os.path.join(args.work, "backtest.json"), out) manifest_add(args.work, "backtest", rules=len(stats), sessions=len(sessions)) for rid, st in stats.items(): print("%-4s opp=%-3d viol=%-3d comp=%-3d %s" % (rid, st["opportunities"], st["violations"], st["compliances"], st["text"][:60])) print("backtest: %d rules x %d sessions -> backtest.json (now SAMPLE-VERIFY before trusting)" % (len(stats), len(sessions))) if __name__ == "__main__": main() -
card.py 13.1 KB
#!/usr/bin/env python3 """Stage 6b — share card + health badge. Generates two SHARE-SAFE artifacts next to report.html: card.svg — a postable 800x418 checkup card: grade, pixel hearts, a one-line doctor's note, aggregate stats, class-distribution bar. Contains NO file paths, NO rule text, NO quotes from the repo, NO session ids — aggregates only. The repo NAME is shown (the card exists to be posted); pass --anonymous to omit it. claude-md-health.svg — a flat README badge ("CLAUDE.md checkup | B") colored by grade, for the coverage-badge loop. The doctor's note comes from diagnosis.json's optional `share_note` (the model writes it under strict safety rules — see SKILL.md), with dry, deterministic fallbacks keyed to the stats. Usage: python3 card.py --work DIR [--name NAME] [--anonymous] """ import argparse import os import sys sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) from _common import load_json, manifest_add PAL = {"bg": "#f6f7f5", "card": "#ffffff", "line": "#d8dcd6", "ink": "#1c2024", "ink2": "#5b6470", "ink3": "#8a93a0", "accent": "#2f6f5e", "warn": "#9a6d1f", "crit": "#b23c2e", "info": "#5f8fc9"} GRADE_COLOR = {"A": "#2c7a5c", "B": "#2f6f5e", "C": "#9a6d1f", "D": "#b23c2e", "F": "#b23c2e"} CLASS_COLOR = {"hook": "#2f6f5e", "linter": "#5f8fc9", "test": "#5f8fc9", "judge": "#d4a04c", "unclassified": "#8a93a0"} BOT = """<g shape-rendering="crispEdges" transform="translate(%d,%d) scale(%s)"> <rect x="28" y="0" width="8" height="4" fill="#5fd4c8"/><rect x="30" y="4" width="4" height="4" fill="#93a0af"/> <rect x="12" y="8" width="40" height="24" fill="#93a0af"/> <rect x="40" y="8" width="4" height="4" fill="#d4a04c"/><rect x="44" y="4" width="8" height="8" fill="#d4a04c"/><rect x="46" y="6" width="4" height="4" fill="#fdfdfc"/> <rect x="16" y="16" width="32" height="8" fill="#2b3440"/> <rect x="20" y="18" width="6" height="4" fill="#5fd4c8"/><rect x="38" y="18" width="6" height="4" fill="#5fd4c8"/> <rect x="26" y="27" width="12" height="2" fill="#2b3440"/> <rect x="26" y="32" width="12" height="4" fill="#93a0af"/> <rect x="14" y="34" width="36" height="24" fill="#93a0af"/><rect x="16" y="36" width="32" height="20" fill="#fdfdfc"/> <rect x="30" y="40" width="4" height="12" fill="#b23c2e"/><rect x="26" y="44" width="12" height="4" fill="#b23c2e"/> </g>""" HEART = ('<g transform="translate(%d,%d)" fill="%s"%s><rect x="2" y="0" width="4" height="2"/>' '<rect x="8" y="0" width="4" height="2"/><rect x="0" y="2" width="14" height="4"/>' '<rect x="2" y="6" width="10" height="2"/><rect x="4" y="8" width="6" height="2"/>' '<rect x="6" y="10" width="2" height="2"/></g>') FONT = "-apple-system,'Segoe UI',Roboto,Helvetica,sans-serif" MONO = "ui-monospace,Menlo,Consolas,monospace" def esc(s): return (str(s).replace("&", "&").replace("<", "<") .replace(">", ">").replace('"', """)) def hearts_svg(x, y, grade): n = {"A": 5, "B": 4, "C": 3, "D": 2, "F": 1}.get((grade or " ")[0].upper(), 0) color = GRADE_COLOR.get((grade or " ")[0].upper(), PAL["ink3"]) out = [] for i in range(5): dim = "" if i < n else ' opacity="0.22"' out.append(HEART % (x + i * 18, y, color, dim)) return "".join(out) def fallback_note(grade, verdict_counts, criticals, warns, intake_mode=False): g = (grade or " ")[0].upper() if intake_mode: return "The chart was in the history all along." if verdict_counts.get("ignored"): return "The loudest rule was the broken one." if g == "A": return "Clean bill of health. Stay boring." if criticals: return "The chart no longer matches the patient." if warns: return "Mostly healthy. The numbers rotted first." return "Examined. Nothing to report — which is a report." def wrap(text, width=52, max_lines=2): words, lines, cur = text.split(), [], "" for w in words: if len(cur) + len(w) + 1 > width and cur: lines.append(cur) cur = w else: cur = (cur + " " + w).strip() if cur: lines.append(cur) if len(lines) > max_lines: lines = lines[:max_lines] lines[-1] = lines[-1].rstrip(".,;") + "…" return lines def build_card(name, grade, note, stats, class_counts): gcol = GRADE_COLOR.get((grade or " ")[0].upper(), PAL["ink3"]) note_lines = wrap(note) note_svg = "".join( '<text x="60" y="%d" font-size="26" font-weight="600" fill="%s" ' 'font-family="%s">%s</text>' % (196 + i * 36, PAL["ink"], FONT, esc(l)) for i, l in enumerate(note_lines)) # class-distribution mini bar (aggregate-safe) total = sum(class_counts.values()) or 1 bar, bx = [], 60 for cls in ("hook", "linter", "test", "judge", "unclassified"): n = class_counts.get(cls, 0) if not n: continue w = max(int(340.0 * n / total), 6) bar.append('<rect x="%d" y="330" width="%d" height="12" fill="%s"/>' % (bx, w, CLASS_COLOR[cls])) bx += w legend = " · ".join("%s %d" % (c, n) for c, n in class_counts.items() if n) stat_rows = "".join( '<text x="60" y="%d" font-size="14" fill="%s" font-family="%s">%s</text>' % (268 + i * 22, PAL["ink2"], FONT, esc(s)) for i, s in enumerate(stats)) title = ('<text x="128" y="88" font-size="26" font-weight="700" fill="%s" ' 'font-family="%s">%s</text>' % (PAL["ink"], FONT, esc(name)) if name else "") # viewBox only (no fixed width/height): scales to any viewer instead of # clipping in containers narrower than 800px return """<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 800 418"> <rect width="800" height="418" fill="%(bg)s"/> <rect x="14" y="14" width="772" height="390" rx="16" fill="%(card)s" stroke="%(line)s"/> %(bot)s <text x="128" y="60" font-size="12" letter-spacing="2.5" fill="%(ink2)s" font-family="%(mono)s">CLAUDE-MD-DOCTOR · CHECKUP</text> %(title)s <rect x="648" y="40" width="104" height="66" rx="3" fill="%(card)s" stroke="%(gcol)s" stroke-width="3"/> <rect x="654" y="110" width="104" height="4" fill="%(line)s"/> <text x="700" y="78" font-size="34" font-weight="700" fill="%(gcol)s" text-anchor="middle" font-family="%(mono)s">%(grade)s</text> %(hearts)s <path d="M46 140 H210 V134 H214 V140 H250 V144 H254 V140 H420 V132 H424 V145 H428 V140 H580 V134 H584 V140 H620 V144 H624 V140 H754" fill="none" stroke="%(accent)s" stroke-width="2" shape-rendering="crispEdges"/> %(note)s %(stats)s %(bar)s <text x="60" y="358" font-size="11" fill="%(ink3)s" font-family="%(font)s">%(legend)s</text> <text x="60" y="386" font-size="13" fill="%(ink2)s" font-family="%(font)s">give your CLAUDE.md a checkup</text> <text x="740" y="386" font-size="12" text-anchor="end" fill="%(ink3)s" font-family="%(mono)s">npx skills add agent-clinic/claude-md-doctor</text> </svg>""" % {"bg": PAL["bg"], "card": PAL["card"], "line": PAL["line"], "ink2": PAL["ink2"], "ink3": PAL["ink3"], "accent": PAL["accent"], "mono": MONO, "font": FONT, "gcol": gcol, "grade": esc(grade), "bot": BOT % (44, 40, "1.05"), "title": title, "hearts": hearts_svg(656, 88, grade), "note": note_svg, "stats": stat_rows, "bar": "".join(bar), "legend": esc(legend)} def build_badge(grade): gcol = GRADE_COLOR.get((grade or " ")[0].upper(), PAL["ink3"]) label, value = "CLAUDE.md checkup", "grade %s" % grade lw, vw = 10 + len(label) * 7, 10 + len(value) * 7 return """<svg xmlns="http://www.w3.org/2000/svg" width="%d" height="20" role="img" aria-label="%s: %s"> <clipPath id="r"><rect width="%d" height="20" rx="3"/></clipPath> <g clip-path="url(#r)"> <rect width="%d" height="20" fill="#555"/> <rect x="%d" width="%d" height="20" fill="%s"/> </g> <g fill="#fff" text-anchor="middle" font-family="Verdana,Geneva,DejaVu Sans,sans-serif" font-size="11"> <text x="%d" y="14">%s</text> <text x="%d" y="14" font-weight="bold">%s</text> </g> </svg>""" % (lw + vw, esc(label), esc(value), lw + vw, lw, lw, vw, gcol, lw // 2, esc(label), lw + vw // 2, esc(value)) def main(): ap = argparse.ArgumentParser() ap.add_argument("--work", required=True) ap.add_argument("--name", default=None, help="display name on the card (default: repo basename)") ap.add_argument("--anonymous", action="store_true", help="omit the repo name entirely") args = ap.parse_args() work = args.work intake = load_json(os.path.join(work, "intake.json")) or {} vitals = load_json(os.path.join(work, "vitals.json")) or {} backtest = load_json(os.path.join(work, "backtest.json")) or {} rulebook = load_json(os.path.join(work, "rulebook.json")) or {} diagnosis = load_json(os.path.join(work, "diagnosis.json")) or {} chart = load_json(os.path.join(work, "chart.json")) or {} grade = diagnosis.get("grade", "—") name = "" if args.anonymous else \ (args.name or os.path.basename(intake.get("repo", "")) or "") # aggregate-only stats — never a path, rule text, or repo quote. # class strings are model-authored: whitelist them so a stray value # can never ride the legend onto the postable card def norm_class(cls): return cls if cls in ("hook", "linter", "test", "judge") \ else "unclassified" combined = vitals.get("launch_loaded_combined", {}) rules = rulebook.get("rules", []) class_counts, lawable, already = {}, 0, 0 for r in rules: enf = r.get("enforcement") or {} cls = norm_class(enf.get("class") or "unclassified") class_counts[cls] = class_counts.get(cls, 0) + 1 if cls in ("hook", "linter", "test"): if (enf.get("current_layer") or "prose") == "prose": lawable += 1 else: already += 1 # intake mode: the patient has no memory file — the chart was mined intake_mode = chart.get("mode") == "intake" if not rules and chart.get("rules"): for r in chart["rules"]: cls = norm_class(r.get("class") or "unclassified") class_counts[cls] = class_counts.get(cls, 0) + 1 verdict_counts = {} for v in (diagnosis.get("rule_verdicts") or {}).values(): verdict_counts[v.get("verdict", "?")] = \ verdict_counts.get(v.get("verdict", "?"), 0) + 1 criticals = len([d for d in diagnosis.get("diagnoses", []) if d.get("severity") == "critical"]) warns = len([d for d in diagnosis.get("diagnoses", []) if d.get("severity") == "warn"]) note = diagnosis.get("share_note") or fallback_note( grade, verdict_counts, criticals, warns, intake_mode) stats = [] if intake_mode: idx = load_json(os.path.join(work, "sessions_index.json")) or {} n_sess = len([s for s in idx.get("sessions", []) if s.get("events")]) stats.append("no memory file on record · %d session%s examined" % (n_sess, "" if n_sess == 1 else "s")) stats.append("%d fact%s + %d rule%s mined from history" % (len(chart.get("facts", [])), "" if len(chart.get("facts", [])) == 1 else "s", len(chart.get("rules", [])), "" if len(chart.get("rules", [])) == 1 else "s")) tax = chart.get("startup_tax") or {} if tax.get("est_tokens"): stats.append("~%s tokens of transcript re-discovering the same " "facts (est.)" % "{:,}".format(tax["est_tokens"])) elif combined: stats.append("%s effective lines · ~%s tokens loaded every session" % ("{:,}".format(combined.get("effective_lines", 0)), "{:,}".format(combined.get("est_tokens", 0)))) if rules and not intake_mode: stats.append("%d directives · %d could be laws · %d already are" % (len(rules), lawable, already)) sev_order = ("ignored", "abandoned", "mixed", "undocumented", "healthy", "inert", "unmeasured") vbits = [("%d %s" % (verdict_counts[k], k)) for k in sev_order if verdict_counts.get(k)] vbits += [("%d %s" % (n, k)) for k, n in verdict_counts.items() if n and k not in sev_order] if vbits: stats.append(" · ".join(vbits)) stats = stats[:3] # the card's stat block holds three lines above the bar out_dir = os.path.dirname(work) card_path = os.path.join(out_dir, "card.svg") badge_path = os.path.join(out_dir, "claude-md-health.svg") with open(card_path, "w", encoding="utf-8") as f: f.write(build_card(name, grade, note, stats, class_counts)) with open(badge_path, "w", encoding="utf-8") as f: f.write(build_badge(grade)) manifest_add(work, "card", grade=grade) print("card: %s" % card_path) print("badge: %s" % badge_path) print("badge snippet: []" "(https://github.com/agent-clinic/claude-md-doctor)" % os.path.basename(badge_path)) if __name__ == "__main__": main() -
compile.py 8.7 KB
#!/usr/bin/env python3 """Stage 4e — compile: turn backtested rules into review-then-arm enforcement proposals. NOTHING here is installed automatically — hooks execute shell, so every artifact lands in <work>/enforcement/ for a human to read, edit, and wire in themselves. Matchers were validated against real history first ("backtest before you arm"); the arming level per rule comes from its violation-cause mix. Emits: enforcement/PROPOSALS.md — per-rule dossier: class, evidence, arming, snippet enforcement/rules-guard.json — machine config for the generic guard enforcement/claude_md_doctor_guard.py — generic PreToolUse guard (warn|block per rule) enforcement/settings-snippet.json — hooks fragment to merge into .claude/settings.json Usage: python3 compile.py --work DIR Reads: <work>/rulebook.json, <work>/backtest.json """ import argparse import json import os import sys sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) from _common import load_json, manifest_add, save_json GUARD = '''#!/usr/bin/env python3 """claude-md-doctor generated PreToolUse guard — REVIEW BEFORE ARMING. Reads the hook payload on stdin, applies the regexes in rules-guard.json to Bash commands and Edit/Write content, and per rule either warns (stderr, non-blocking) or blocks (exit 2 — Claude sees the message and must adjust). Flip a rule's "mode" between "warn" and "block" in rules-guard.json. """ import json, os, re, sys HERE = os.path.dirname(os.path.abspath(__file__)) cfg = json.load(open(os.path.join(HERE, "rules-guard.json"))) payload = json.load(sys.stdin) tool = payload.get("tool_name", "") inp = payload.get("tool_input") or {} if tool == "Bash": text = inp.get("command", "") kind = "bash" elif tool in ("Edit", "Write", "MultiEdit", "NotebookEdit"): text = "PATH: %s\\n%s" % (inp.get("file_path", ""), inp.get("new_string") or inp.get("content") or "") kind = "edit" else: sys.exit(0) blocked = [] for rule in cfg["rules"]: if kind not in rule.get("kinds", ["bash", "edit"]): continue if re.search(rule["violation"], text): msg = "[claude-md-doctor guard] %s: %s (%s)" % ( rule["id"], rule["text"], rule.get("source", "")) if rule.get("mode") == "block": blocked.append(msg) else: print(msg + " — warning only", file=sys.stderr) if blocked: print("\\n".join(blocked), file=sys.stderr) sys.exit(2) ''' SETTINGS_SNIPPET = { "hooks": { "PreToolUse": [ { "matcher": "Bash|Edit|Write|MultiEdit", "hooks": [ {"type": "command", "command": "python3 .claude/hooks/claude_md_doctor_guard.py"} ] } ] } } def dossier(rule, st): enf = rule.get("enforcement") or {} lines = ["## %s — %s" % (rule["id"], rule.get("text", "")), ""] lines.append("- **Class**: %s%s · scope %s · today: %s" % (enf.get("class", "unclassified"), " (%s)" % enf["subtype"] if enf.get("subtype") else "", enf.get("scope_kind", "file"), enf.get("current_layer", "prose"))) if enf.get("mechanism"): lines.append("- **Mechanism**: %s" % enf["mechanism"]) if st: causes = ", ".join("%s ×%d" % (k, v) for k, v in sorted(st.get("causes", {}).items())) or "none" lines.append("- **Backtest evidence**: %d opportunities, %d violations " "(%s), %d compliances" % (st.get("opportunities", 0), st.get("violations", 0), causes, st.get("compliances", 0))) lines.append("- **Recommended arming**: %s" % st.get("arming", "n/a")) m = rule.get("matchers") or {} if enf.get("class") == "hook" and m.get("violation"): lines.append("- **Guard entry** (added to rules-guard.json, mode `warn`):") lines.append("") lines.append("```json") lines.append(json.dumps(guard_entry(rule, st), indent=2)) lines.append("```") elif enf.get("class") in ("linter", "test"): lines.append("- **Encode it**: %s — this binds every agent and every " "human; prose then becomes a one-line pointer." % (enf.get("mechanism") or "add a lint rule / discipline test")) elif rule.get("ordering"): lines.append("- **Stop-gate candidate**: enforce '%s' with a Stop hook " "that checks the session transcript for the required " "command after the last file edit (template in PROPOSALS " "header)." % rule["ordering"].get("desc", "")) lines.append("") return "\n".join(lines) def clip(s, cap=120): """Word-boundary truncation — this text is the armed hook's warning.""" if len(s) <= cap: return s return s[:cap].rsplit(" ", 1)[0].rstrip(",;:") + "…" def guard_entry(rule, st): scope = rule.get("scope") or {} kinds = [] for e in scope.get("events", []): if e == "bash": kinds.append("bash") if e in ("edit", "write"): kinds.append("edit") mode = "warn" if st and st.get("causes", {}).get("defiance-proven"): mode = "block" return {"id": rule["id"], "text": clip(rule.get("text", "")), "source": "%s:%s" % ((rule.get("source") or {}).get("file", "?"), (rule.get("source") or {}).get("line", "?")), "kinds": sorted(set(kinds)) or ["bash", "edit"], "violation": rule["matchers"]["violation"], "mode": mode} def main(): ap = argparse.ArgumentParser() ap.add_argument("--work", required=True) args = ap.parse_args() rulebook = load_json(os.path.join(args.work, "rulebook.json")) backtest = load_json(os.path.join(args.work, "backtest.json")) or {} if not rulebook: sys.exit("compile: missing rulebook.json") per_rule = backtest.get("per_rule", {}) out_dir = os.path.join(args.work, "enforcement") os.makedirs(out_dir, exist_ok=True) hook_rules = [r for r in rulebook["rules"] if (r.get("enforcement") or {}).get("class") == "hook" and (r.get("matchers") or {}).get("violation") and ((r.get("enforcement") or {}).get("current_layer") or "prose") == "prose"] guard_cfg = {"_note": "REVIEW EACH ENTRY BEFORE ARMING. mode: warn|block.", "rules": [guard_entry(r, per_rule.get(r["id"])) for r in hook_rules]} head = [ "# Enforcement proposals — REVIEW BEFORE ARMING", "", "Generated by claude-md-doctor from rules validated against this repo's", "own session history (matchers were sample-verified; arming levels come", "from each rule's violation-cause mix). Nothing here is installed", "automatically. To arm the guard:", "", "1. Read every entry below and `rules-guard.json`.", "2. Copy `claude_md_doctor_guard.py` and `rules-guard.json` into " "`.claude/hooks/`.", "3. Merge `settings-snippet.json` into `.claude/settings.json`.", "4. Rules default to `warn`; flip to `block` only after a clean warn " "period (defiance-proven rules start at block — the reminder already " "happened and lost).", "", "Evidence: prose/memory alone leaves large violation rates even when the", "rule is demonstrably seen (TRACE arXiv:2606.13174: 57.5% violated with", "memory access; compiled checks cut violations to 2–38%). Hooks are the", "official mechanism for must-happen rules.", "", "Goodhart caution (SpecBench arXiv:2605.21384): a check the agent can", "see can be satisfied without honoring the rule. Where possible, prefer", "gates that verify real outcomes (run the actual tests) over pattern", "proxies, and expect pattern-gates to need occasional judge audits.", "", "---", "", ] body = [dossier(r, per_rule.get(r["id"])) for r in rulebook["rules"]] with open(os.path.join(out_dir, "PROPOSALS.md"), "w") as f: f.write("\n".join(head + body)) save_json(os.path.join(out_dir, "rules-guard.json"), guard_cfg) with open(os.path.join(out_dir, "claude_md_doctor_guard.py"), "w") as f: f.write(GUARD) save_json(os.path.join(out_dir, "settings-snippet.json"), SETTINGS_SNIPPET) manifest_add(args.work, "compile", hook_rules=len(hook_rules), total_rules=len(rulebook["rules"])) print("compile: %d rule dossier(s), %d guard entr%s -> %s" % (len(rulebook["rules"]), len(hook_rules), "y" if len(hook_rules) == 1 else "ies", out_dir)) if __name__ == "__main__": main() -
generate.py 7.3 KB
#!/usr/bin/env python3 """Stage B4 — generate: assemble a proposed CLAUDE.md from the curated chart. Input is a model-authored <work>/chart.json: the judged survivors of the history mining (mine.py found candidates; the model kept only recurrent, still-current ones and wrote receipts). This script is a deterministic assembler — it adds nothing, it only formats what the chart contains, and it holds the draft to the same vitals this tool diagnoses in other people's files: a draft over the official 200-line target is an error, not a warning. Provenance rides in HTML comments (`<!-- seen 4× across 3 sessions -->`) — Claude Code strips those at load, so receipts cost the reviewer nothing. chart.json schema (model-authored): { "mode": "intake", # intake = no memory file exists; # gap = additions to an existing file "facts": [{"text": "Tests: `pnpm test:unit` from the repo root.", "section": "Commands", # optional grouping heading "family": "rediscovery", # correction|failure_recovery| # rediscovery|denial|preamble "occurrences": 7, "sessions": 5, "provenance": "optional override for the receipt comment", "evidence": [{"session": "…", "turn": 3, "excerpt": "…"}]}], "rules": [{"id": "MR1", "text": "Use pnpm, never npm.", "family": "failure_recovery", "class": "hook", # hook|linter|test|judge (taxonomy) "occurrences": 4, "sessions": 3, "evidence": [{"session": "…", "turn": 9, "excerpt": "…"}]}], "startup_tax": {"est_tokens": 30000, "sessions": 12}, # optional "declined": [{"text": "…", "reason": "one-off / stale since July"}] } `family` values are singular (they key report.py's labels); candidates.json's top-level keys are the plural family names — map corrections→correction, denials→denial, preambles→preamble when carrying items over. Usage: python3 generate.py --work DIR [--out FILE] Reads: <work>/chart.json, <work>/intake.json Writes: <work>/../PROPOSED-CLAUDE.md (mode intake) <work>/../PROPOSED-ADDITIONS.md (mode gap) Never writes into the repo root — adoption is the user's move. """ import argparse import os import sys sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) from _common import load_json, manifest_add from vitals import SIZE_TARGET_LINES, measure LEAN_TARGET_LINES = 60 # a mined draft should be far under the official cap def receipt(item): if item.get("provenance"): # "--" inside an HTML comment ends or corrupts it return item["provenance"].replace("--", "–") n, s = item.get("occurrences"), item.get("sessions") if n and s: return "seen %d× across %d session%s" % (n, s, "" if s == 1 else "s") return "mined from session history" def body_text(s): """Fact/rule text goes OUTSIDE comments — but a stray comment delimiter in it would swallow the rest of the draft at load time.""" return s.rstrip().replace("<!--", "<!--").replace("-->", "-->") def render(chart, repo_name, sessions_n): mode = chart.get("mode", "intake") lines = [] if mode == "intake": lines.append("# %s" % repo_name) else: lines.append("# Proposed additions to CLAUDE.md") lines += [ "", "<!-- Proposed by claude-md-doctor from %d session(s) of this repo's" % sessions_n, " own history. Every line below recurred in real sessions; receipts", " are in report.html. Review each one — delete anything you would", " not bet on — then %s. -->" % ("save as CLAUDE.md in the repo root" if mode == "intake" else "merge into the existing file"), "", ] # Facts, grouped by their optional section heading. Unsectioned facts # always render FIRST — after a heading they would silently read as # members of the previous section. sections, order = {}, [] for f in chart.get("facts", []): key = f.get("section") or "" if key not in sections: sections[key] = [] order.append(key) sections[key].append(f) for key in ([""] if "" in sections else []) + [k for k in order if k]: if key: lines += ["## %s" % key, ""] for f in sections[key]: lines.append("%s <!-- %s -->" % (body_text(f["text"]), receipt(f))) lines.append("") rules = chart.get("rules", []) if rules: lines += ["## Rules", ""] for r in rules: note = receipt(r) if r.get("class") == "hook": note += "; hook-enforceable — guard proposal in the exam folder" elif r.get("class") in ("linter", "test"): note += "; better as a lint rule/test — see the report" lines.append("- %s <!-- %s -->" % (body_text(r["text"]), note)) lines.append("") return "\n".join(lines).rstrip() + "\n" def main(): ap = argparse.ArgumentParser() ap.add_argument("--work", required=True) ap.add_argument("--out", default=None) args = ap.parse_args() # normalize: with a relative --work like ".", dirname("") would drop the # draft INSIDE the work dir instead of next to the report args.work = os.path.abspath(args.work) chart = load_json(os.path.join(args.work, "chart.json")) intake = load_json(os.path.join(args.work, "intake.json")) or {} if not chart: sys.exit("generate: missing chart.json (write it first — see SKILL.md Mode B)") if not (chart.get("facts") or chart.get("rules")): sys.exit("generate: chart.json has no facts and no rules — nothing to propose") mode = chart.get("mode", "intake") index = load_json(os.path.join(args.work, "sessions_index.json")) or {} sessions_n = len([s for s in index.get("sessions", []) if s.get("events")]) repo_name = os.path.basename(intake.get("repo", "")) or "This repository" name = "PROPOSED-CLAUDE.md" if mode == "intake" else "PROPOSED-ADDITIONS.md" out_path = args.out or os.path.join(os.path.dirname(args.work), name) with open(out_path, "w", encoding="utf-8") as f: f.write(render(chart, repo_name, sessions_n)) m = measure(out_path) or {} eff, toks = m.get("effective_lines", 0), m.get("est_tokens", 0) over = mode == "intake" and eff > SIZE_TARGET_LINES manifest_add(args.work, "generate", mode=mode, out=out_path, facts=len(chart.get("facts", [])), rules=len(chart.get("rules", [])), effective_lines=eff, over_target=over) print("generate: %s — %d fact(s), %d rule(s), %d effective lines (~%d tokens)" % (out_path, len(chart.get("facts", [])), len(chart.get("rules", [])), eff, toks)) if over: # the doctor must not prescribe the disease it diagnoses sys.exit("generate: draft is %d effective lines — OVER the official " "%d-line target this tool exists to enforce. Trim chart.json " "(keep only rules you would bet on) and re-run." % (eff, SIZE_TARGET_LINES)) if eff > LEAN_TARGET_LINES: print("generate: note — %d lines is legal but not lean; a mined draft " "usually earns under %d" % (eff, LEAN_TARGET_LINES)) if __name__ == "__main__": main() -
intake.py 12.1 KB
#!/usr/bin/env python3 """Stage 1 — intake: discover the memory surface a Claude Code session loads. Finds project/nested/rules/ancestor CLAUDE.md files, follows @imports (depth 4, fence/backtick-aware), honors claudeMdExcludes, detects the pointer-to-AGENTS.md pattern, and locates the repo's session-history directory. Usage: python3 intake.py --repo /path/to/repo [--work DIR] [--include-user] Writes: <work>/intake.json """ import argparse import fnmatch import os import re import sys sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) from _common import (iter_lines, load_json, manifest_add, read_text, save_json, sha1_of, strip_html_comments, strip_inline_code, parse_frontmatter) SKIP_DIRS = {".git", "node_modules", ".venv", "venv", "__pycache__", "dist", "build", ".next", "target", ".claude-md-doctor"} IMPORT_RE = re.compile(r"(?:^|\s)@([~./A-Za-z0-9_][A-Za-z0-9_.~/\\-]*)") MAX_IMPORT_DEPTH = 4 def file_record(path, scope, loaded, repo, external=False, **extra): text = read_text(path) rec = { "path": path, "rel": os.path.relpath(path, repo) if path.startswith(repo + os.sep) else None, "scope": scope, "loaded_at_launch": loaded, "external": external, "exists": text is not None, "size_bytes": len(text.encode("utf-8", "replace")) if text is not None else 0, "sha1": sha1_of(text) if text is not None else None, "is_symlink": os.path.islink(path), "symlink_target": os.path.realpath(path) if os.path.islink(path) else None, "excluded": False, "excluded_by": None, } rec.update(extra) return rec def find_imports(path, text): """@path imports outside fences and inline code (backticked = literal).""" found = [] for lineno, line, in_fence in iter_lines(text): if in_fence: continue clean = strip_inline_code(line) for m in IMPORT_RE.finditer(clean): ref = m.group(1).rstrip(".,;:)") if re.match(r"^[A-Za-z0-9_.-]+$", ref) and "." not in ref and "/" not in ref: continue # bare @word (a mention/handle), not a path import found.append({"from": path, "from_line": lineno, "ref": ref}) return found def resolve_ref(ref, containing_file): if ref.startswith("~"): return os.path.expanduser(ref) if os.path.isabs(ref): return ref return os.path.normpath(os.path.join(os.path.dirname(containing_file), ref)) def collect_excludes(repo): patterns = [] for settings in ( os.path.join(repo, ".claude", "settings.json"), os.path.join(repo, ".claude", "settings.local.json"), os.path.expanduser("~/.claude/settings.json"), ): data = load_json(settings) if isinstance(data, dict): for pat in data.get("claudeMdExcludes", []) or []: patterns.append({"pattern": pat, "source": settings}) return patterns def apply_excludes(files, patterns): for rec in files: for p in patterns: if fnmatch.fnmatch(rec["path"], p["pattern"]): rec["excluded"], rec["excluded_by"] = True, p["pattern"] break def detect_pointer(rec): """A CLAUDE.md that delegates to AGENTS.md is the officially recommended pattern. Three styles exist in the wild: symlink — works (Claude Code follows it) import — a bare `@AGENTS.md` file; works (import loads at launch) bare-text — a regular file containing just `AGENTS.md` with no `@`: looks like a pointer but the target NEVER loads. Broken. (Caveat: raw fetches of symlinks also look like this.) """ rec["is_pointer"], rec["pointer_style"], rec["pointer_targets"] = False, None, [] if rec["is_symlink"]: rec.update(is_pointer=True, pointer_style="symlink", pointer_targets=[rec["symlink_target"]]) return text = read_text(rec["path"]) or "" clean, _ = strip_html_comments(text) meaningful = [l.strip() for l in clean.splitlines() if l.strip()] if not meaningful or len(meaningful) > 3: return imports = [l for l in meaningful if re.fullmatch(r"@\S+", l)] if imports and all(re.fullmatch(r"@\S+", l) or l.startswith("#") for l in meaningful): rec.update(is_pointer=True, pointer_style="import", pointer_targets=[resolve_ref(l[1:], rec["path"]) for l in imports]) return if len(meaningful) == 1 and re.fullmatch(r"[A-Za-z0-9_./-]+\.md", meaningful[0]): target = resolve_ref(meaningful[0], rec["path"]) if os.path.isfile(target): rec.update(is_pointer=True, pointer_style="bare-text", pointer_targets=[target]) CWD_RE = re.compile(r'"cwd"\s*:\s*"([^"]+)"') def _recorded_cwd(d, files): """The working directory a transcript recorded for itself.""" for fn in files[:2]: try: with open(os.path.join(d, fn), errors="replace") as f: for _ in range(40): line = f.readline() if not line: break m = CWD_RE.search(line) if m: return m.group(1) except OSError: continue return None def sessions_dir_for(repo): """Locate this repo's transcript directory. Claude Code slugifies the path (/, . and spaces all become -), so try the slug first. When that misses — a space, a non-ASCII character, any future change to the scheme — fall back to the transcripts themselves, which record the cwd they ran in. Guessing wrong here is expensive: the exam silently skips the backtest and the report reads as "no session history" when the history was sitting right there. """ base = os.path.expanduser("~/.claude/projects") if not os.path.isdir(base): return {"dir": None, "session_files": 0} # A session may have run under a symlinked spelling of the same repo # (/tmp vs /private/tmp on macOS), so try both when slugifying. real = os.path.realpath(repo) cands = [] for form in (repo, real): cands += [re.sub(r"[/. ]", "-", form), re.sub(r"[/.]", "-", form), form.replace("/", "-")] for cand in cands: d = os.path.join(base, cand) if os.path.isdir(d): n = len([f for f in os.listdir(d) if f.endswith(".jsonl")]) return {"dir": d, "session_files": n, "matched_by": "slug"} for name in sorted(os.listdir(base)): d = os.path.join(base, name) if not os.path.isdir(d): continue files = [f for f in os.listdir(d) if f.endswith(".jsonl")] if not files: continue cwd = _recorded_cwd(d, files) if cwd and os.path.realpath(cwd) == real: return {"dir": d, "session_files": len(files), "matched_by": "recorded-cwd"} return {"dir": None, "session_files": 0} def main(): ap = argparse.ArgumentParser() ap.add_argument("--repo", default=os.getcwd()) ap.add_argument("--work", default=None) ap.add_argument("--include-user", action="store_true") args = ap.parse_args() repo = os.path.realpath(args.repo) work = args.work or os.path.join(repo, ".claude-md-doctor", "work") files, edges = [], [] # Project root files (launch-loaded) for name in ("CLAUDE.md", os.path.join(".claude", "CLAUDE.md"), "CLAUDE.local.md"): p = os.path.join(repo, name) if os.path.lexists(p): files.append(file_record(p, "project", True, repo)) # Orphaned AGENTS.md: present for other agents, but with no CLAUDE.md # pointing at it, Claude Code loads nothing — a top-tier diagnosis. agents = os.path.join(repo, "AGENTS.md") if not files and os.path.isfile(agents): files.append(file_record(agents, "orphan-agents", False, repo, orphaned=True)) # Nested (on-demand) + rules for dirpath, dirnames, filenames in os.walk(repo): dirnames[:] = [d for d in dirnames if d not in SKIP_DIRS] rel_dir = os.path.relpath(dirpath, repo) in_rules = os.sep.join([".claude", "rules"]) in os.path.join(rel_dir, "") for fn in filenames: p = os.path.join(dirpath, fn) if in_rules and fn.endswith(".md"): meta, _ = parse_frontmatter(read_text(p) or "") paths = meta.get("paths") or [] if isinstance(paths, str): paths = [paths] files.append(file_record(p, "rules", not paths, repo, rules_paths=paths)) elif fn in ("CLAUDE.md", "CLAUDE.local.md") and dirpath != repo \ and ".claude" not in dirpath.split(os.sep): files.append(file_record(p, "nested", False, repo)) # Ancestors above the repo (launch-loaded, but not the repo's to fix) parent = os.path.dirname(repo) while parent and parent != os.path.dirname(parent): for name in ("CLAUDE.md", "CLAUDE.local.md"): p = os.path.join(parent, name) if os.path.isfile(p): files.append(file_record(p, "ancestor", True, repo, external=True)) parent = os.path.dirname(parent) # User scope (opt-in for diagnosis; existence always noted) user_claude = os.path.expanduser("~/.claude/CLAUDE.md") user_rules_dir = os.path.expanduser("~/.claude/rules") user_note = { "user_claude_md_exists": os.path.isfile(user_claude), "user_rules_count": len([f for f in os.listdir(user_rules_dir) if f.endswith(".md")]) if os.path.isdir(user_rules_dir) else 0, "included_in_exam": bool(args.include_user), } if args.include_user and os.path.isfile(user_claude): files.append(file_record(user_claude, "user", True, repo, external=True)) # Managed policy (noted, never diagnosed) managed = [p for p in ("/Library/Application Support/ClaudeCode/CLAUDE.md", "/etc/claude-code/CLAUDE.md") if os.path.isfile(p)] apply_excludes(files, collect_excludes(repo)) # Pointer detection on project-root CLAUDE.md for rec in files: if rec["scope"] == "project" and rec["exists"]: detect_pointer(rec) # Follow @imports from every loaded, non-excluded file frontier = [(rec["path"], rec["loaded_at_launch"], 0) for rec in files if rec["exists"] and not rec["excluded"]] known = {rec["path"] for rec in files} while frontier: path, loaded, depth = frontier.pop(0) if depth >= MAX_IMPORT_DEPTH: continue text = read_text(path) if text is None: continue for imp in find_imports(path, text): resolved = resolve_ref(imp["ref"], path) exists = os.path.isfile(resolved) edges.append({**imp, "resolved": resolved, "exists": exists, "depth": depth + 1}) if exists and resolved not in known: known.add(resolved) files.append(file_record( resolved, "import", loaded, repo, external=not resolved.startswith(repo + os.sep))) frontier.append((resolved, loaded, depth + 1)) effective = [f["path"] for f in files if f["exists"] and not f["excluded"] and f["loaded_at_launch"] and not f["external"]] out = { "repo": repo, "files": files, "import_edges": edges, "effective_launch_loaded": effective, "user_scope": user_note, "managed_policy_files": managed, "sessions": sessions_dir_for(repo), } save_json(os.path.join(work, "intake.json"), out) manifest_add(work, "intake", files=len(files), imports=len(edges), effective=len(effective)) print("intake: %d files (%d launch-loaded in-repo), %d import edges -> %s" % (len(files), len(effective), len(edges), os.path.join(work, "intake.json"))) if __name__ == "__main__": main() -
mine.py 20 KB
#!/usr/bin/env python3 """Stage B1 (and 4f) — mine: extract rule/fact candidates from the sessions. The inverse of the backtest: instead of replaying a memory file's rules over the history, mine the history for the rules that were never written down. Five signal families, each mechanically pre-filtered here and JUDGED by the model afterwards (high recall, moderate precision — the calibration corpus put the lexical markers at ~65–75% precision, which is a judge feed, not a verdict): corrections user messages that redirect the agent or state a durable preference ("use pnpm not npm", "make sure we never …") failure_recovery a failed command followed by a similar one that worked (npm test → pnpm test; the fix IS the rule) rediscovery commands run in the first turns of many sessions — facts the agent re-derives every time because no file states them denials tool calls the user rejected at the permission prompt or a settings rule denied (auto-mode classifier blocks are counted but NOT proposed — that is the harness, not the user) preambles near-identical session-opening explanations — context the user keeps retyping Recurrence gates keep one-off taste out: grouped families need >=2 sessions or >=3 occurrences — except rediscovery, which needs >=3 distinct sessions strictly (a command run ten times in one session is a loop). Corrections are UNGATED: they stay ungrouped (wording varies), deduped, and capped newest-first — judge them hardest. Groups whose last occurrence is older than the median session are flagged `stale` — a preference the repo may have moved past must not resurface. Usage: python3 mine.py --work DIR [--max-corrections N] Reads: <work>/sessions/*.json, <work>/sessions_index.json Writes: <work>/candidates.json """ import argparse import os import re import shlex import sys sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) from _common import est_tokens, load_json, manifest_add, save_json from backtest import ENV_PREFIX_RE, command_word MIN_SESSIONS = 2 # a grouped signal recurring in this many sessions … MIN_OCCURRENCES = 3 # … or this many times total survives the gate REDISCOVERY_MIN_SESSIONS = 3 # noisier family, higher bar EARLY_TURNS = 3 # "session start" = the first three user turns STARTUP_TAX_TURN = 4 # bytes before this turn = the re-discovery tax MAX_SAMPLES = 3 SIM_THRESHOLD = 0.5 # token overlap for failed→retry pairing PREAMBLE_SIM = 0.55 PREAMBLE_MIN_CHARS = 40 # --- user-text exclusions (verified against a real 2.1.x transcript corpus) --- # Automation injections (<ci-monitor-event> etc.) are stamped origin "human", # so metadata alone cannot filter them: any leading <tag> is excluded. TAG_OPEN_RE = re.compile(r"^\s*<[A-Za-z][\w-]*[ >]") WRAPPER_MARKERS = ("<command-name>", "<local-command-stdout>", "<local-command-caveat>") SYS_REMINDER_RE = re.compile(r"<system-reminder>.*?(</system-reminder>|$)", re.S) INTERRUPT_TEXTS = {"[Request interrupted by user]", "[Request interrupted by user for tool use]"} # --- correction markers, calibrated on 624 real user messages (precision # noted per pattern; the union fires on ~8% of messages at ~65-75%) --- MARKERS = [ ("pivot", r"(?im)^(no|nope|nah|don'?t|stop|never|wait|but|actually|hmm)\b"), # ~86% ("you-said", r"(?i)\b(you (said|previously said|claimed|told me)" r"|didn'?t you (already )?say|why (did|are|would|do) you" r"|i thought (you|i (told|asked)))\b"), # 83% ("still-broken", r"(?i)\bstill\b.{0,40}\b(wrong|broken|fail(s|ing|ed)?" r"|not work(ing)?|empty|missing|off)\b"), # 100%, small n ("instead-of", r"(?i)\binstead of\b"), # 75% ("not-right", r"(?i)\b(wrong|not what i (asked|meant|want(ed)?)" r"|that'?s not (what|right|it))\b"), # 73% ("standing", r"(?im)\b(from now on|going forward|always remember)\b" r"|\bmake sure\b[^.\n]{0,60}\b(always|never|only)\b" r"|^\s*[-*]?\s*never [a-z]+" r"|\blet'?s (use|follow) the rules?\b"), # standing prefs ] # 0% precision on session-opening probe prompts — only counts mid-session GATED_MARKERS = [ ("dont-verb", r"(?i)\b(don'?t|do not|never|stop) " r"(use|do|touch|run|add|create|delete|commit|push)\b"), ] MARKERS_C = [(n, re.compile(p)) for n, p in MARKERS] GATED_C = [(n, re.compile(p)) for n, p in GATED_MARKERS] # fallback for condensed events without the `denial` field (older condensations) DENIAL_PREFIXES = ( ("user-rejected", "The user doesn't want to proceed"), ("automode-blocked", "Permission for this action was denied by the Claude " "Code auto mode classifier"), ("permission-rule", "Permission to use "), ) def clean_user_text(text): """Human text or None. Excludes command wrappers, automation injections, interruption markers; strips system-reminder spans.""" if not text: return None if text.strip() in INTERRUPT_TEXTS: return None if any(m in text for m in WRAPPER_MARKERS) or text.startswith("Caveat:"): return None # strip reminder spans BEFORE the tag check — a reminder can be # prepended to genuine human text in the same record text = SYS_REMINDER_RE.sub(" ", text).strip() if not text or TAG_OPEN_RE.match(text): return None return text def denial_kind(ev): if ev.get("denial"): return ev["denial"] text = ev.get("text", "") for kind, prefix in DENIAL_PREFIXES: if text.startswith(prefix): return kind return None def tokens_of(text): return set(t.lower() for t in re.findall(r"[A-Za-z0-9_./-]{2,}", text or "")) def similar(a, b): """Overlap coefficient, not Jaccard — a 2-token command pair like `npm test` → `pnpm test` must still score 0.5.""" ta, tb = tokens_of(a), tokens_of(b) if not ta or not tb: return 0.0 return len(ta & tb) / float(min(len(ta), len(tb))) def head_key(cmd): """First two meaningful words of a command — the rediscovery grouping key (`cat package.json`, `pnpm install`, `git log`).""" words = [] # `cd <dir> && <real work>` is navigation, not the thing being rediscovered: # key on the first segment that does actual work, else fall back to the cd. for seg in re.split(r"&&|\|\||;|\|", cmd or ""): seg = ENV_PREFIX_RE.sub("", seg.strip()) if not seg: continue try: w = shlex.split(seg) # quote-aware: `cd "/my repo" && ...` except ValueError: # unbalanced quotes — fall back w = [x.strip("'\"") for x in seg.split()] if not w or not w[0]: continue words = words or w if os.path.basename(w[0]) not in ("cd", "pushd", "popd"): words = w break if not words or not words[0]: return "?" head = os.path.basename(words[0])[:24] if len(words) > 1 and re.match(r"^[A-Za-z0-9_./@-]+$", words[1]) \ and not words[1].startswith("-"): return "%s %s" % (head, os.path.basename(words[1])[:32]) return head def mine_session(events, sess_id): """One condensed session -> raw findings for cross-session grouping.""" out = {"corrections": [], "pairs": [], "early_cmds": [], "denials": [], "opener": None, "startup_bytes": None, "automode": 0} tool_count, opener_seen = 0, False bash_idx = [i for i, e in enumerate(events) if e["t"] == "tool" and e.get("name") == "Bash"] id_idx = {e["id"]: i for i, e in enumerate(events) if e["t"] == "tool" and e.get("id")} prev_kind = [None] * len(events) # rolling context for after-interrupt for i, ev in enumerate(events): t = ev["t"] if out["startup_bytes"] is None and ev.get("turn", 0) >= STARTUP_TAX_TURN: out["startup_bytes"] = ev.get("off", 0) if t == "tool": tool_count += 1 if ev.get("name") == "Bash" and ev.get("turn", 0) <= EARLY_TURNS: # offset gap to the next event ≈ this call's record + result # bytes — the attributable cost of one discovery command span = max(0, events[i + 1].get("off", 0) - ev.get("off", 0)) \ if i + 1 < len(events) else 0 out["early_cmds"].append((head_key(ev.get("command", "")), ev.get("command", ""), span)) continue if t == "tool_error": kind = denial_kind(ev) if kind == "automode-blocked": out["automode"] += 1 continue # what call was this? by id when the condenser carried one # (batched calls make "nearest preceding" wrong), else proximity src = None if ev.get("for_id") in id_idx: src = events[id_idx[ev["for_id"]]] else: for j in range(i - 1, max(-1, i - 5), -1): if events[j]["t"] == "tool": src = events[j] break if kind: key = command_word(src.get("command", "")) \ if src and src.get("name") == "Bash" \ else (src or {}).get("name", "?") out["denials"].append( {"kind": kind, "tool": (src or {}).get("name", "?"), "key": key, "session": sess_id, "turn": ev.get("turn", 0), "ts": ev.get("ts"), "cmd": (src or {}).get("command", "")[:160]}) elif src and src.get("name") == "Bash": # genuine failure: does a similar command follow and differ? failed = src.get("command", "") nxt = next((events[k] for k in bash_idx if k > i), None) if nxt is not None: retry = nxt.get("command", "") if retry != failed and similar(failed, retry) >= SIM_THRESHOLD: out["pairs"].append( {"failed": failed[:200], "retry": retry[:200], "session": sess_id, "turn": ev.get("turn", 0), "ts": ev.get("ts")}) continue if t == "user": text = clean_user_text(ev.get("text", "")) if text is None: prev_kind[i] = ("interrupt" if ev.get("text", "").strip() in INTERRUPT_TEXTS else None) continue if not opener_seen: opener_seen = True # only the session's FIRST human text opens it if len(text) >= PREAMBLE_MIN_CHARS: out["opener"] = {"session": sess_id, "text": text[:300], "ts": ev.get("ts")} signals = [n for n, rx in MARKERS_C if rx.search(text)] if tool_count >= 3 and len(text) < 300: signals += [n for n, rx in GATED_C if rx.search(text)] after_interrupt = any( prev_kind[j] == "interrupt" for j in range(max(0, i - 3), i)) or any( events[j]["t"] == "tool_error" and denial_kind(events[j]) == "user-rejected" for j in range(max(0, i - 3), i)) if after_interrupt: signals.append("after-interrupt") if signals: if tool_count >= 3 and len(text) < 120: signals.append("mid-session") # judge feature, not a gate out["corrections"].append( {"session": sess_id, "turn": ev.get("turn", 0), "ts": ev.get("ts"), "text": text[:300], "signals": signals}) return out def main(): ap = argparse.ArgumentParser() ap.add_argument("--work", required=True) ap.add_argument("--max-corrections", type=int, default=120) args = ap.parse_args() index = load_json(os.path.join(args.work, "sessions_index.json")) if not index: sys.exit("mine: run sessions.py first (missing sessions_index.json)") sess_dir = os.path.join(args.work, "sessions") sessions = [s for s in index.get("sessions", []) if s.get("events") and s.get("tools", 0) > 0] if not sessions: save_json(os.path.join(args.work, "candidates.json"), {"meta": {"sessions_scanned": 0, "note": "no usable sessions"}}) manifest_add(args.work, "mine", sessions=0) print("mine: no usable sessions to mine") return last_ts_sorted = sorted(s.get("last_ts") or "" for s in sessions) median_ts = last_ts_sorted[len(last_ts_sorted) // 2] corrections, pair_groups, early_groups = [], {}, {} denial_groups, openers, taxes = {}, [], [] automode_total = 0 seen_correction_texts = set() # forked sessions duplicate whole prefixes for sess in sessions: # index order = newest first events = load_json(os.path.join(sess_dir, sess["id"] + ".json")) or [] found = mine_session(events, sess["id"]) automode_total += found["automode"] if found["startup_bytes"]: taxes.append(found["startup_bytes"]) if found["opener"]: openers.append(found["opener"]) for c in found["corrections"]: key = re.sub(r"\s+", " ", c["text"].lower()).strip() if key in seen_correction_texts: continue seen_correction_texts.add(key) corrections.append(c) for p in found["pairs"]: key = (command_word(p["failed"]), command_word(p["retry"])) g = pair_groups.setdefault( key, {"failed_word": key[0], "retry_word": key[1], "occurrences": 0, "_sessions": set(), "last_ts": "", "samples": []}) g["occurrences"] += 1 g["_sessions"].add(p["session"]) g["last_ts"] = max(g["last_ts"], p.get("ts") or "") if len(g["samples"]) < MAX_SAMPLES: g["samples"].append(p) for key, cmd, span in found["early_cmds"]: g = early_groups.setdefault( key, {"key": key, "occurrences": 0, "_sessions": set(), "bytes": 0, "samples": []}) g["occurrences"] += 1 g["_sessions"].add(sess["id"]) g["bytes"] += span if len(g["samples"]) < MAX_SAMPLES: g["samples"].append({"session": sess["id"], "cmd": cmd[:160]}) for d in found["denials"]: key = (d["kind"], d["tool"], d["key"]) g = denial_groups.setdefault( key, {"kind": d["kind"], "tool": d["tool"], "key": d["key"], "occurrences": 0, "_sessions": set(), "last_ts": "", "samples": []}) g["occurrences"] += 1 g["_sessions"].add(d["session"]) g["last_ts"] = max(g["last_ts"], d.get("ts") or "") if len(g["samples"]) < MAX_SAMPLES: g["samples"].append(d) def finish(groups, min_sessions=MIN_SESSIONS, occ_fallback=True): out = [] for g in groups: sess_set = g.pop("_sessions") g["sessions"] = len(sess_set) if g["sessions"] >= min_sessions or \ (occ_fallback and g["occurrences"] >= MIN_OCCURRENCES): if g.get("last_ts"): g["stale"] = g["last_ts"] < median_ts out.append(g) return sorted(out, key=lambda g: (-g["sessions"], -g["occurrences"])) pairs = finish(list(pair_groups.values())) early_sessions = {g["key"]: set(g["_sessions"]) for g in early_groups.values()} # rediscovery needs CROSS-session recurrence — a command run ten times # in one session is a loop, not a re-discovery rediscovery = finish(list(early_groups.values()), min_sessions=REDISCOVERY_MIN_SESSIONS, occ_fallback=False) denials = finish(list(denial_groups.values())) # preambles: cluster near-identical session openers (union-find on # pairwise token overlap); identical texts are usually forked-session # echoes, so support counts distinct sessions but flags echo clusters parent = list(range(len(openers))) def find(i): while parent[i] != i: parent[i] = parent[parent[i]] i = parent[i] return i for i in range(len(openers)): for j in range(i + 1, len(openers)): if similar(openers[i]["text"], openers[j]["text"]) >= PREAMBLE_SIM: parent[find(i)] = find(j) clusters = {} for i, o in enumerate(openers): clusters.setdefault(find(i), []).append(o) preambles = [] for members in clusters.values(): sess_set = {m["session"] for m in members} if len(sess_set) < MIN_SESSIONS: continue texts = {m["text"] for m in members} preambles.append({"sessions": len(sess_set), "occurrences": len(members), "identical_echo": len(texts) == 1, "samples": members[:MAX_SAMPLES]}) preambles.sort(key=lambda p: -p["sessions"]) corrections = corrections[:args.max_corrections] # tax = bytes attributable to the RECURRING discovery commands only — # bytes-before-turn-N would blame long first tasks for "re-discovery" tax = {} tax_bytes = sum(g.get("bytes", 0) for g in rediscovery) if tax_bytes: tax_sessions = set() for g in rediscovery: tax_sessions |= early_sessions.get(g["key"], set()) tax = {"est_tokens": est_tokens(tax_bytes), "sessions": len(tax_sessions)} if taxes: taxes.sort() tax["early_window_median_bytes"] = taxes[len(taxes) // 2] out = { "meta": { "sessions_scanned": len(sessions), "gates": {"min_sessions": MIN_SESSIONS, "min_occurrences": MIN_OCCURRENCES, "rediscovery_min_sessions": REDISCOVERY_MIN_SESSIONS, "note": "rediscovery: sessions gate only (no occurrence " "fallback); corrections: ungated, judge them"}, "corrections_found": len(seen_correction_texts), "corrections_kept": len(corrections), "automode_blocked": automode_total, "known_gaps": "bare factual corrections without lexical markers " "escape the pre-filter; the judge pass may scan a " "few raw sessions to spot-check recall", }, "corrections": corrections, "failure_recovery": pairs, "rediscovery": rediscovery, "denials": denials, "preambles": preambles, "startup_tax": tax, } save_json(os.path.join(args.work, "candidates.json"), out) manifest_add(args.work, "mine", sessions=len(sessions), corrections=len(corrections), pairs=len(pairs), rediscovery=len(rediscovery), denials=len(denials), preambles=len(preambles)) print("mine: %d session(s) -> %d correction(s), %d failed→fixed pair " "group(s), %d rediscovery group(s), %d denial group(s), " "%d preamble cluster(s)%s -> candidates.json" % (len(sessions), len(corrections), len(pairs), len(rediscovery), len(denials), len(preambles), ", ~%d-token re-discovery tax" % tax["est_tokens"] if tax.get("est_tokens") else "")) if automode_total: print("mine: %d auto-mode classifier block(s) counted but not " "proposed — that is the harness, not the user" % automode_total) if __name__ == "__main__": main() -
refcheck.py 10.4 KB
#!/usr/bin/env python3 """Stage 3 — records check: do the things the memory files point at exist? Deterministic extraction and existence-checking of file paths, globs, commands (package.json scripts / Makefile targets), and rules-file path scopes. Ambiguous extractions are emitted with status "review" for the model to judge — the script never guesses. Usage: python3 refcheck.py --work DIR Reads: <work>/intake.json Writes: <work>/refcheck.json """ import argparse import glob as globmod import itertools import json import os import re import sys sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) from _common import iter_lines, load_json, manifest_add, read_text, save_json PATH_EXTS = (".md", ".markdown", ".json", ".jsonc", ".ts", ".tsx", ".js", ".jsx", ".mjs", ".py", ".rb", ".go", ".rs", ".css", ".scss", ".html", ".yml", ".yaml", ".toml", ".txt", ".sh", ".sql", ".proto", ".d.ts", ".env", ".xml", ".swift", ".java", ".c", ".h", ".cpp") URL_RE = re.compile(r"^[a-z][a-z0-9+.-]*://|^mailto:", re.I) BACKTICK_RE = re.compile(r"`([^`\n]+)`") CMD_RE = re.compile( r"\b(pnpm run|pnpm|npm run|yarn run|yarn|bun run|make|just)\s+" r"([A-Za-z0-9:._-]+)") PNPM_BUILTINS = {"install", "i", "add", "remove", "rm", "update", "up", "dlx", "exec", "publish", "link", "why", "list", "ls", "outdated", "audit", "store", "config", "setup", "import", "rebuild", "prune", "patch", "create", "init", "-v", "--version"} YARN_BUILTINS = PNPM_BUILTINS | {"workspaces", "workspace", "dedupe", "info"} API_PATH_RE = re.compile(r"^/(v\d+|api|graphql)(/|$)") def looks_pathish(token, allow_space=False): if URL_RE.search(token) or token.startswith("@"): return False if " " in token and not allow_space: return False if API_PATH_RE.match(token): return False # /v1/... style API endpoint, not a filesystem path if any(ch in token for ch in "<>{}$()|;"): return False if not re.search(r"[A-Za-z0-9]", token): return False # pure punctuation like /** or --- is never a path if "/" in token: tail = r"[A-Za-z0-9_.*/\[\] ~-]*$" if allow_space else r"[A-Za-z0-9_.*/\[\]~-]*$" return bool(re.match(r"^[~./A-Za-z0-9_*\[\]-]" + tail, token)) return token.endswith(PATH_EXTS) and len(token) > len(".x") def expand_braces(pattern): m = re.search(r"\{([^{}]*)\}", pattern) if not m: return [pattern] head, tail = pattern[:m.start()], pattern[m.end():] out = [] for opt in m.group(1).split(","): out.extend(expand_braces(head + opt + tail)) return out[:1000] def check_path(token, containing_file, repo): """Return (status, detail). Statuses: ok | ok_external | missing | machine_specific | glob_ok | glob_empty.""" tok = os.path.expanduser(token) is_glob = any(c in tok for c in "*[") if is_glob: if os.path.isabs(tok): return "review", "absolute glob — not expanded (safety cap)" bases = [repo, os.path.dirname(containing_file)] for base in bases: n = 0 for pat in expand_braces(tok): # iglob + islice caps the walk so a broad pattern can't crawl # the world (or node_modules) to exhaustion it = globmod.iglob(os.path.join(base, pat), recursive=True) n += sum(1 for _ in itertools.islice(it, 500)) if n >= 500: break if n: return "glob_ok", ("%d matches" % n) if n < 500 else "500+ matches" return "glob_empty", "no matches from repo root or file dir" candidates = ([tok] if os.path.isabs(tok) else [os.path.join(repo, tok), os.path.join(os.path.dirname(containing_file), tok)]) for cand in candidates: if os.path.exists(cand): inside = os.path.realpath(cand).startswith(repo + os.sep) return ("ok" if inside else "ok_external"), cand if os.path.isabs(tok): m = re.match(r"^/(Users|home)/([^/]+)/", tok) me = os.path.basename(os.path.expanduser("~")) if m and m.group(2) != me: return "machine_specific", "absolute path under another user's home" return "missing", "absolute path not found on this machine" return "missing", "not found from repo root or containing dir" def load_command_targets(repo): scripts, make_targets = {}, set() pkg = load_json(os.path.join(repo, "package.json")) if isinstance(pkg, dict): scripts = {k: True for k in (pkg.get("scripts") or {})} mk = read_text(os.path.join(repo, "Makefile")) if mk: for line in mk.splitlines(): m = re.match(r"^([A-Za-z0-9_.-]+)\s*:([^=]|$)", line) if m and not m.group(1).startswith("."): make_targets.add(m.group(1)) return scripts, make_targets def check_command(runner, target, scripts, make_targets, has_pkg, has_make): if runner in ("pnpm", "yarn"): builtins = PNPM_BUILTINS if runner == "pnpm" else YARN_BUILTINS if target in builtins: return "builtin", "" if runner == "make": if not has_make: return "review", "no Makefile at repo root" return ("ok", "") if target in make_targets else ("missing", "no such Makefile target") if not has_pkg: return "review", "no package.json at repo root" return ("ok", "") if target in scripts else ("missing", "no such script in package.json") def main(): ap = argparse.ArgumentParser() ap.add_argument("--work", required=True) args = ap.parse_args() intake = load_json(os.path.join(args.work, "intake.json")) if not intake: sys.exit("refcheck: run intake.py first (missing intake.json)") repo = intake["repo"] scripts, make_targets = load_command_targets(repo) has_pkg = os.path.isfile(os.path.join(repo, "package.json")) has_make = os.path.isfile(os.path.join(repo, "Makefile")) references, commands, seen = [], [], set() examined = [r for r in intake["files"] if r["exists"] and not r["excluded"] and not r["external"]] for rec in examined: text = read_text(rec["path"]) or "" for lineno, line, in_fence in iter_lines(text): # inline code + fenced code are prime territory for paths/commands tokens = BACKTICK_RE.findall(line) if in_fence and line.strip() and not line.strip().startswith(("```", "~~~")): tokens.append(line.strip()) for token in tokens: token = token.strip() for m in CMD_RE.finditer(token): runner = m.group(1).split()[0] target = m.group(2) key = ("cmd", runner, target) if key in seen: continue seen.add(key) status, detail = check_command(runner, target, scripts, make_targets, has_pkg, has_make) commands.append({"file": rec["path"], "line": lineno, "command": "%s %s" % (m.group(1), target), "status": status, "detail": detail}) parts = re.split(r"[\s,]+", token) whole = token.strip("().,;:\'\"") spaced = False # A backticked span can be ONE path that contains spaces # ("02 Private/Life/The Backyard Chickens.md"). Splitting that # invents dead references out of its fragments, so try the span # whole first and only fall back to the pieces. if " " in whole and "/" in whole \ and looks_pathish(whole, allow_space=True) \ and (whole.endswith(PATH_EXTS) or os.path.exists(os.path.join(repo, whole))): parts, spaced = [whole], True for part in parts: part = part.strip("().,;:'\"") if not looks_pathish(part, allow_space=spaced): continue key = ("path", part) if key in seen: continue seen.add(key) status, detail = check_path(part, rec["path"], repo) references.append({"file": rec["path"], "line": lineno, "ref": part, "status": status, "detail": detail}) # Import edges that failed to resolve are dead references too for edge in intake["import_edges"]: if not edge["exists"]: references.append({"file": edge["from"], "line": edge["from_line"], "ref": "@" + edge["ref"], "status": "missing", "detail": "import target not found: " + edge["resolved"]}) # Rules files whose paths: scope matches nothing rule_scopes = [] for rec in intake["files"]: if rec["scope"] != "rules" or not rec.get("rules_paths"): continue total = 0 for pat in rec["rules_paths"]: for expanded in expand_braces(pat): total += len(globmod.glob(os.path.join(repo, expanded), recursive=True)) rule_scopes.append({"file": rec["path"], "patterns": rec["rules_paths"], "matches": total, "status": "ok" if total else "dead_scope"}) bad = [r for r in references if r["status"] in ("missing", "machine_specific", "glob_empty")] out = { "references": references, "commands": commands, "rule_scopes": rule_scopes, "stats": { "files_examined": len(examined), "references_checked": len(references), "references_bad": len(bad), "commands_checked": len(commands), "commands_missing": len([c for c in commands if c["status"] == "missing"]), }, } save_json(os.path.join(args.work, "refcheck.json"), out) manifest_add(args.work, "refcheck", **out["stats"]) print("refcheck: %d refs (%d bad), %d commands (%d missing) -> refcheck.json" % (len(references), len(bad), len(commands), out["stats"]["commands_missing"])) if __name__ == "__main__": main() -
report.py 37.3 KB
#!/usr/bin/env python3 """Stage 5 — report: assemble the doctor's report from the exam artifacts. Reads intake/vitals/refcheck.json plus diagnosis.json (written by the model — grade, diagnoses, prescriptions, stale-claim verdicts) and renders a single self-contained HTML report plus a machine-readable report.json. Verifies the work-state manifest: a skipped exam stage is disclosed, never papered over. Usage: python3 report.py --work DIR [--out FILE] """ import argparse import html import os import sys import time sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) from _common import load_json, save_json, manifest_add VERSION = "0.5.1" CLASS_PILL = {"hook": "p-ok", "linter": "p-info", "test": "p-info", "judge": "p-warn"} CLASS_BAR = {"hook": "var(--accent)", "linter": "var(--info)", "test": "var(--info)", "judge": "var(--warn)", "unclassified": "var(--ink3)"} HEART_RECTS = ('<rect x="2" y="0" width="4" height="2"/><rect x="8" y="0" width="4" height="2"/>' '<rect x="0" y="2" width="14" height="4"/><rect x="2" y="6" width="10" height="2"/>' '<rect x="4" y="8" width="6" height="2"/><rect x="6" y="10" width="2" height="2"/>') def build_hearts(grade): """Condition meter: pixel hearts filled by grade (A=5 … F=1).""" n = {"A": 5, "B": 4, "C": 3, "D": 2, "F": 1}.get((grade or " ")[0].upper(), 0) out = [] for i in range(5): style = "" if i < n else ";opacity:.22" out.append('<svg width="13" height="12" viewBox="0 0 14 12" ' 'shape-rendering="crispEdges" style="margin:0 1px%s">' '<g fill="currentColor">%s</g></svg>' % (style, HEART_RECTS)) return "".join(out) REQUIRED_STAGES = ("intake", "vitals", "refcheck", "diagnosis") CITATIONS = { "official-200": ("Claude Code memory doc — “Size: target under 200 lines per CLAUDE.md file. Longer files consume more context and reduce adherence.”", "https://code.claude.com/docs/en/memory"), "official-best-practices": ("Claude Code best practices — deletion test; “Bloated CLAUDE.md files cause Claude to ignore your actual instructions!”; sparse emphasis.", "https://code.claude.com/docs/en/best-practices"), "official-hooks": ("Claude Code memory doc — CLAUDE.md is “context, not enforced configuration”; use a PreToolUse hook to block an action regardless.", "https://code.claude.com/docs/en/memory"), "official-4mib": ("Claude Code memory doc — a CLAUDE.md over 4 MiB is skipped entirely.", "https://code.claude.com/docs/en/memory"), "official-40k": ("Claude Code troubleshooting — startup warning at 40,000 characters of memory content.", "https://code.claude.com/docs/en/troubleshooting"), "eth": ("Gloaguen et al. (ETH Zurich), “Evaluating AGENTS.md” (v2): context files cost +20–23% inference with no significant success change; agents measurably comply with explicit directives; “describe only minimal requirements.”", "https://arxiv.org/abs/2602.11988"), "mcmillan": ("McMillan, factorial study of 1,650 Claude Code sessions: no detectable structural effect of file size/position/contradictions in tested range; within-session adherence decay ≈ 5.6%/function.", "https://arxiv.org/abs/2605.10039"), "ifscale": ("IFScale: adherence 98.4% at 100 instructions → 84.8% at 250 → 68.9% at 500; errors shift to silent omission.", "https://arxiv.org/abs/2507.11538"), "agentif": ("AGENTIF (NeurIPS 2025): best model satisfies all constraints of an agentic instruction 27.2% of the time; ≈0 past 6,000 words.", "https://arxiv.org/abs/2505.16944"), "sysbench": ("SysBench: system-message constraint adherence decays ≈12.8pp/turn over 5 turns (GPT-4o).", "https://arxiv.org/abs/2408.10943"), "levy": ("Levy, Jacoby & Goldberg (ACL 2024): accuracy 0.92 → 0.68 with 3,000 tokens of padding; degradation starts ≈500 tokens.", "https://arxiv.org/abs/2402.14848"), "chroma": ("Chroma “Context Rot”: 18 models degrade with input length even on trivial tasks; focused ~300-token prompts beat 113k-token contexts.", "https://www.trychroma.com/research/context-rot"), "longllmlingua": ("LongLLMLingua (ACL 2024): +21.4% at ~4× prompt compression — cutting filler can raise performance.", "https://arxiv.org/abs/2310.06839"), "caps": ("Dillitzer et al., “Attention is Case-Sensitive”: uppercase shifts +1.85pp accuracy; near-zero on reasoning models; saturation spends the effect.", "https://arxiv.org/abs/2608.03711"), "sigil": ("SIGIL: prose agents perform 56% of the steps their own skill mandates while outputs still pass checks; script-compiled harnesses reach 86%.", "https://arxiv.org/abs/2607.27309"), "trace": ("TRACE: with memory alone, 57.5% of applicable user-preference checks are still violated; compiling corrections into runtime checks cuts violations from 100% to 2–38%.", "https://arxiv.org/abs/2606.13174"), "harness-if": ("Harness-IF: models comply 3.6–7.4pp worse on rules that oppose their unprompted defaults — high compliance on a with-prior rule may be coincidence, not obedience; skill/tool descriptions rank below project files in precedence.", "https://arxiv.org/abs/2608.11727"), "iheval": ("IHEval (NAACL 2025): when instructions conflict, all models drop sharply — the best open-source model resolves conflicts at only 48%. Contradictory rules measurably degrade compliance, and the winner is not reliably the rule you intended.", "https://arxiv.org/abs/2502.08745"), "hierarchy": ("OpenAI Instruction Hierarchy: models are trained to prioritize privileged instructions — a project-file rule that fights trained-in behavior starts at a disadvantage; some rules need hooks because the prior will win.", "https://arxiv.org/abs/2404.13208"), "specbench": ("SpecBench: agents saturate visible checks while holdout gaps grow ~28pp per 10× code size — a check the agent can see may be satisfied without honoring the rule.", "https://arxiv.org/abs/2605.21384"), "agent-readmes": ("“Agent READMEs” (2,303 context files): tests 75.9% / implementation 70.8% / architecture 68.1%; security & performance nearly absent; files accrete without pruning.", "https://arxiv.org/abs/2511.12884"), "cursor-rules": ("Jiang & Nam (MSR 2026), 401 repos: five-theme content taxonomy; ~28.7% duplicated lines across a repo's rules files.", "https://arxiv.org/abs/2512.18925"), "awm": ("Agent Workflow Memory: induced, selectively-loaded workflows +51.1% relative on WebArena — recurring procedures belong in on-demand skills.", "https://arxiv.org/abs/2409.07429"), "unblocked": ("Unblocked, “Audit a bloated CLAUDE.md in 7 steps” — the manual audit this tool automates.", "https://getunblocked.com/blog/audit-fix-bloated-claude-md/"), "surface-bloat": ("“Too Many CLAUDE.md and Skill Files?” — aggregate memory surfaces fail silently (60 files ≈ 64k standing tokens); fixes: consolidate → thin router/index → one-screen invariants + on-demand procedures.", "https://xtrace.ai/blog/too-many-claude-skill-files"), } SEV_PILL = {"critical": "p-crit", "warn": "p-warn", "info": "p-info", "ok": "p-ok"} def esc(s): return html.escape(str(s if s is not None else ""), quote=True) def cite(ids, order): sups = [] for cid in ids or []: if cid not in CITATIONS: continue if cid not in order: order.append(cid) sups.append('<sup><a href="#fn-%s">[%d]</a></sup>' % (cid, order.index(cid) + 1)) return "".join(sups) def build_patient(intake, vitals, backtest=None): rows = [] def row(k, v): rows.append("<tr><th style='width:220px'>%s</th><td>%s</td></tr>" % (k, v)) row("Repository", "<code>%s</code>" % esc(intake["repo"])) files = [f for f in intake["files"] if f["exists"] and not f["excluded"]] internal = [f for f in files if not f["external"]] row("Files examined", "%d in-repo (%d launch-loaded), %d external noted" % (len(internal), len(intake["effective_launch_loaded"]), len(files) - len(internal))) pointers = [f for f in files if f.get("is_pointer")] if pointers: row("Pointer pattern", "CLAUDE.md points at %s (healthy pattern; the target was examined)" % ", ".join("<code>%s</code>" % esc(os.path.basename(t)) for f in pointers for t in f["pointer_targets"])) excl = [f for f in intake["files"] if f["excluded"]] if excl: row("Excluded via claudeMdExcludes", ", ".join("<code>%s</code>" % esc(f["rel"] or f["path"]) for f in excl)) us = intake.get("user_scope", {}) row("User-scope memory", ("~/.claude/CLAUDE.md %s, %d user rules — %s" % ("present" if us.get("user_claude_md_exists") else "absent", us.get("user_rules_count", 0), "included in exam" if us.get("included_in_exam") else "not examined (repo scope only)"))) sess = intake.get("sessions", {}) examined = ("replayed in the Rulebook section below" if backtest else "not examined in this run") row("Session history found", ("%d transcript(s) at <code>%s</code> — %s" % (sess.get("session_files", 0), esc(sess.get("dir")), examined)) if sess.get("dir") else "none found for this path on this machine") return "\n".join(rows) def build_vitals(vitals, order): c = vitals["launch_loaded_combined"] t = vitals["thresholds"] biggest = max(vitals["per_file"].items(), key=lambda kv: kv[1]["effective_lines"], default=(None, None)) cards = [] def card(v, k, note, cls=""): cards.append('<div class="card %s"><div class="v">%s</div>' '<div class="k">%s</div><div class="n">%s</div></div>' % (cls, v, k, note)) if biggest[0]: b = biggest[1] over = b["effective_lines"] > t["size_target_lines"] card(b["effective_lines"], "lines — %s" % esc(os.path.basename(biggest[0])), "official target: under %d%s" % (t["size_target_lines"], cite(["official-200"], order)), "bad" if over else "fine") card("{:,}".format(c["est_tokens"]), "est. tokens loaded every session", "%.2f%% of a %dk context (estimate)" % (c["pct_of_context"], t["context_budget_tokens"] // 1000), "warned" if c["est_tokens"] > 3000 else "fine") card("%d" % c["effective_lines"], "launch-loaded lines (all files)", "startup warning at %d chars: %s%s" % (t["startup_warn_chars"], "TRIGGERED" if c["startup_warning"] else "not triggered", cite(["official-40k"], order)), "bad" if c["startup_warning"] else "fine") return "\n".join(cards) def build_files_table(vitals): rows = [] for path, m in sorted(vitals["per_file"].items(), key=lambda kv: -kv[1]["effective_lines"]): if m["external"] and m["scope"] != "ancestor": continue markers = [] if m["is_pointer"]: markers.append('<span class="pill p-ok">pointer</span>') if m["over_size_target"]: markers.append('<span class="pill p-crit">over 200</span>') if m["init_boilerplate"]: markers.append('<span class="pill p-warn">/init boilerplate</span>') if m["emphasis_per_100_lines"] > 15: markers.append('<span class="pill p-warn">emphasis %s/100</span>' % m["emphasis_per_100_lines"]) if m["dated_per_100_lines"] > 10: markers.append('<span class="pill p-warn">dated entries</span>') if m["scope"] == "ancestor": markers.append('<span class="pill p-info">ancestor</span>') if m["comment_lines_removed"]: markers.append('<span class="pill p-info">%d comment lines free</span>' % m["comment_lines_removed"]) rows.append("<tr><td><code>%s</code></td><td>%s</td><td>%s</td><td>%s</td></tr>" % (esc(os.path.basename(path) if m["scope"] != "nested" else path), m["effective_lines"], m["est_tokens"], " ".join(markers) or "—")) return "\n".join(rows) or ("<tr><td colspan='4' class='sub'>no memory " "files on record — see the Initial chart " "below</td></tr>") def build_lab(refcheck, diagnosis, order): if not refcheck: return "<div class='note'>Records check did not run.</div>" parts = [] flagged = [r for r in refcheck["references"] if r["status"] in ("missing", "machine_specific", "glob_empty")] dismissals = (diagnosis or {}).get("dismissed_refs", []) def dismissal_for(r): # match on ref + line when the dismissal carries a line, else ref only for d in dismissals: if d["ref"] == r["ref"] and ("line" not in d or d["line"] == r["line"]): return d.get("reason", "reviewed: not a real reference") return None dismissed = {(r["ref"], r["line"]): dismissal_for(r) for r in flagged} bad_refs = [r for r in flagged if not dismissed[(r["ref"], r["line"])]] dropped = [r for r in flagged if dismissed[(r["ref"], r["line"])]] ok_n = len(refcheck["references"]) - len(flagged) parts.append("<p class='sub'>%d path references checked — %d resolve, " "%d flagged (%d confirmed on review, %d dismissed as false " "positives). %d commands checked — %d missing.</p>" % (len(refcheck["references"]), ok_n, len(flagged), len(bad_refs), len(dropped), len(refcheck["commands"]), refcheck["stats"]["commands_missing"])) if bad_refs: rows = ["<tr><th>Reference</th><th>Where</th><th>Status</th><th>Detail</th></tr>"] for r in bad_refs: pill = {"missing": "p-crit", "machine_specific": "p-crit", "glob_empty": "p-warn"}[r["status"]] rows.append("<tr><td><code>%s</code></td><td class='mono'>%s:%s</td>" "<td><span class='pill %s'>%s</span></td><td>%s</td></tr>" % (esc(r["ref"]), esc(os.path.basename(r["file"])), r["line"], pill, r["status"].replace("_", " "), esc(r["detail"]))) parts.append("<div class='tablebox'><table>%s</table></div>" % "".join(rows)) if dropped: items = "".join("<li><code>%s</code> — %s</li>" % (esc(r["ref"]), esc(dismissed[(r["ref"], r["line"])])) for r in dropped) parts.append("<details><summary>%d flag(s) dismissed on review</summary>" "<ul class='plain foot'>%s</ul></details>" % (len(dropped), items)) missing_cmds = [c for c in refcheck["commands"] if c["status"] == "missing"] if missing_cmds: rows = ["<tr><th>Command</th><th>Where</th><th>Detail</th></tr>"] for c in missing_cmds: rows.append("<tr><td><code>%s</code></td><td class='mono'>%s:%s</td><td>%s</td></tr>" % (esc(c["command"]), esc(os.path.basename(c["file"])), c["line"], esc(c["detail"]))) parts.append("<div style='height:10px'></div><div class='tablebox'><table>%s</table></div>" % "".join(rows)) for rs in refcheck.get("rule_scopes", []): if rs["status"] == "dead_scope": parts.append("<div class='note'>Rules file <code>%s</code> has a <code>paths:</code> scope matching zero files — it never loads.</div>" % esc(os.path.basename(rs["file"]))) claims = (diagnosis or {}).get("stale_claims", []) if claims: rows = ["<tr><th>Claim in file</th><th>Where</th><th>Verdict</th><th>Detail</th></tr>"] for cl in claims: pill = {"verified": "p-ok", "drifted": "p-crit", "unverified": "p-info"}.get(cl["status"], "p-info") rows.append("<tr><td>%s</td><td class='mono'>%s:%s</td>" "<td><span class='pill %s'>%s</span></td><td>%s</td></tr>" % (esc(cl["claim"]), esc(os.path.basename(cl.get("file", ""))), cl.get("line", ""), pill, cl["status"], esc(cl.get("detail", "")))) parts.append("<h2 style='margin-top:22px'>Checkable claims</h2><div class='tablebox'><table>%s</table></div>" % "".join(rows)) return "\n".join(parts) VERDICT_PILL = {"healthy": "p-ok", "ignored": "p-crit", "mixed": "p-warn", "inert": "p-info", "unmeasured": "p-info", "abandoned": "p-crit", "unverified": "p-info", "undocumented": "p-warn"} TL_CLASS = {"edit": "tl-e", "bash": "tl-b", "other": "tl-o"} TL_LABEL = {"edit": "repo file edit", "bash": "shell command", "other": "other tool"} def render_sample(s): """One evidence sample. Ordering samples carry a `viz` timeline and are drawn as a strip: amber = repo edits, blue = shell commands, gray = other; the red-underlined zone is everything after the last edit — where the required command should have appeared.""" head = "<div class='tl-sum'><b>%s</b>%s — %s</div>" % ( esc(s.get("session", "")[:8]), " (turn %s)" % esc(s["turn"]) if s.get("turn") else "", esc(s.get("note") or s.get("excerpt", ""))) viz = s.get("viz") if not viz: return ("<div class='tl-block'>%s%s</div>" % (head, "<pre>%s</pre>" % esc(s["excerpt"]) if s.get("excerpt") and s.get("note") else "")) segs = "".join( "<span class='tl-seg %s%s' style='flex:%d' title='%d× %s%s'></span>" % (TL_CLASS.get(seg["k"], "tl-o"), " tl-after" if seg["after"] else "", max(seg["n"], 1), seg["n"], TL_LABEL.get(seg["k"], seg["k"]), " (after last edit)" if seg["after"] else "") for seg in viz["segments"]) ok = s.get("ok") after = ", ".join("%s ×%d" % (w, n) for w, n in viz["after_cmds"]) \ or "nothing" verdict_mark = ("<span class='tl-ok'>✓ required command ran</span>" if ok else "<span class='tl-x'>✗ required command never ran</span>") return ("<div class='tl-block'>%s<div class='tl'>%s</div>" "<div class='tl-sum'>%d repo edits, last at turn %s · after the last " "edit: %s · %s</div>" "<div class='tl-legend'><span class='tl-key tl-e'></span>repo edits " "<span class='tl-key tl-b'></span>shell <span class='tl-key tl-o'></span>" "other · <span class='tl-key tl-o tl-after'></span>red-underlined = " "after the last edit</div></div>" % (head, segs, viz["edits"], viz.get("last_edit_turn", "?"), esc(after), verdict_mark)) def build_rulebook(rulebook, backtest, diagnosis, work, order, fallback_note, mined=False): """One merged section: enforcement classification + adherence history + cause triage + arming, one row per rule.""" rules = (rulebook or {}).get("rules") or [] per_rule = (backtest or {}).get("per_rule") or {} if not rules and not per_rule: return "<p class='sub'>%s</p>" % esc(fallback_note) parts = [] if backtest and not backtest.get("verified"): parts.append("<div class='note'>Backtest ran but its samples were not " "verified — matcher results below are provisional.</div>") # header stat + class distribution bar counts, lawable, already = {}, 0, 0 for r in rules: enf = r.get("enforcement") or {} cls = enf.get("class") or "unclassified" counts[cls] = counts.get(cls, 0) + 1 if cls in ("hook", "linter", "test"): if (enf.get("current_layer") or "prose") == "prose": lawable += 1 else: already += 1 if rules: bar = "".join( "<span class='tl-seg' style='flex:%d;background:%s' title='%d× %s'></span>" % (c, CLASS_BAR.get(cls, "var(--ink3)"), c, cls) for cls, c in sorted(counts.items(), key=lambda kv: -kv[1])) parts.append( "<p class='sub'><b>%d of %d</b> %s could be laws instead of " "requests (%d more already are — their prose is the pointer). Prose is " "advisory even when demonstrably seen; compiled checks are what change " "behavior.%s</p>" % (lawable, len(rules), "mined rules" if mined else "directives", already, cite(["trace", "sigil", "official-hooks"], order))) parts.append("<div class='tl' style='max-width:420px'>%s</div>" % bar) parts.append("<div class='tl-legend'>" + " ".join( "<span class='tl-key' style='background:%s'></span>%s ×%d" % (CLASS_BAR.get(cls, "var(--ink3)"), cls, c) for cls, c in sorted(counts.items(), key=lambda kv: -kv[1])) + "</div>") if backtest: w = backtest.get("window", {}) n_sess = w.get("sessions_replayed", 0) parts.append( "<p class='sub'>Adherence replayed over <b>%s session%s</b> — %s tool " "calls, %s → %s (%s stubs skipped). %s.%s</p>" % (n_sess, "" if n_sess == 1 else "s", "{:,}".format(w.get("total_tool_calls", 0)), esc((w.get("from") or "")[:10]), esc((w.get("to") or "")[:10]), w.get("stub_sessions_skipped", 0), esc(w.get("machine_note", "")), cite(["mcmillan", "sysbench"], order))) verdicts = (diagnosis or {}).get("rule_verdicts", {}) rows = ["<tr><th>Rule</th><th>Class · enforced today</th>" "<th>History in this window</th><th>Verdict · recommendation</th></tr>"] depth_totals = {"early": 0, "mid": 0, "late": 0} ordered = [(r["id"], r) for r in rules] or [(rid, None) for rid in per_rule] for rid, r in ordered: st = per_rule.get(rid) or {} enf = ((r or {}).get("enforcement") or st.get("enforcement") or {}) for k in depth_totals: depth_totals[k] += (st.get("violations_by_depth") or {}).get(k, 0) v = verdicts.get(rid, {}) opp, viol = st.get("opportunities", 0), st.get("violations", 0) comp_n = st.get("compliances", 0) mech = st.get("mechanized", bool(st)) verdict = v.get("verdict") or ( "unmeasured" if not mech else "inert" if opp == 0 else "healthy" if viol == 0 else "ignored" if comp_n == 0 else "mixed") # one cell tells the story instead of three mostly-empty number columns causes = " ".join( "<span class='pill %s'>%s ×%d</span>" % ("p-crit" if k.startswith("defiance") else "p-warn" if k == "dilution" else "p-info", esc(k.replace("-", " ")), n) for k, n in sorted((st.get("causes") or {}).items())) if not st: history = "<span class='sub'>—</span>" elif not mech: history = "<span class='sub'>not yet mechanized — no matcher to replay</span>" elif opp == 0: history = "<span class='sub'>no matching activity</span>" else: bits = ["%d× applicable" % opp] if viol: bits.append("<b>%d violation%s</b>" % (viol, "" if viol == 1 else "s")) if comp_n: bits.append("%d compliant" % comp_n) if viol + comp_n: bits.append("(%d%% compliance)" % round(100.0 * comp_n / (viol + comp_n))) history = " · ".join(bits) + (("<br>" + causes) if causes else "") text = (r or {}).get("text") or st.get("text", "") shown = text[:90] + ("…" if len(text) > 90 else "") ev = "" samples = ((st.get("samples") or {}).get("violations", []) + (st.get("samples") or {}).get("compliances", [])) if samples or v.get("note"): blocks = ([("<div class='tl-sum'>%s</div>" % esc(v["note"]))] if v.get("note") else []) blocks += [render_sample(s) for s in samples] ev = ("<details%s><summary>evidence</summary>%s</details>" % (" open" if verdict == "ignored" else "", "".join(blocks))) cls = enf.get("class") or "unclassified" rows.append( "<tr><td><span class='mono'>%s</span> %s%s</td>" "<td><span class='pill %s'>%s</span><br><span class='sub'>%s</span></td>" "<td>%s</td>" "<td><span class='pill %s'>%s</span><br><span class='sub'>%s</span></td></tr>" % (esc(rid), esc(shown), ev, CLASS_PILL.get(cls, "p-info"), esc(cls), esc(enf.get("current_layer") or "prose"), history, VERDICT_PILL.get(verdict, "p-info"), esc(verdict), esc(st.get("arming", "not backtested")))) parts.append("<div class='tablebox'><table>%s</table></div>" % "".join(rows)) total_v = sum(depth_totals.values()) if total_v: parts.append("<p class='sub'>Violations by conversation depth: " "%d early (≤3 turns) · %d mid (4–8) · %d late (>8)%s.</p>" % (depth_totals["early"], depth_totals["mid"], depth_totals["late"], cite(["sysbench"], order))) if os.path.isdir(os.path.join(work, "enforcement")): parts.append("<p class='sub'>Generated proposals (review before arming): " "<code>%s</code></p>" % esc(os.path.join(work, "enforcement", "PROPOSALS.md"))) return "\n".join(parts) FAMILY_LABEL = {"correction": "user correction", "failure_recovery": "failed → fixed", "rediscovery": "re-discovered", "denial": "permission denial", "preamble": "repeated preamble"} PLACEMENT_NOTE = {"hook": "hook proposal written — review-then-arm", "linter": "encode as a lint rule — binds humans too", "test": "encode as a test — binds humans too", "judge": "prose; only a judge can score it"} def build_chart(chart, work, order): """Mined-from-history section: the initial chart for a file-less repo (mode intake) or the undocumented-rules gap analysis (mode gap).""" if not chart or not (chart.get("facts") or chart.get("rules")): return "" mode = chart.get("mode", "intake") facts, rules = chart.get("facts", []), chart.get("rules", []) title = ("Initial chart — mined from history" if mode == "intake" else "Undocumented rules — in the sessions, not the chart") draft = os.path.join(os.path.dirname(work), "PROPOSED-CLAUDE.md" if mode == "intake" else "PROPOSED-ADDITIONS.md") parts = [] if mode == "intake": parts.append( "<p class='sub'>No memory file exists for this repo, so the doctor " "took a history instead: the session transcripts were mined for " "recurring signals — corrections dictated in chat, failed→fixed " "command pairs, facts re-discovered at every session start, " "permission denials. <b>%d fact%s and %d rule%s</b> recurred and " "survived review; each ships with receipts.%s</p>" % (len(facts), "" if len(facts) == 1 else "s", len(rules), "" if len(rules) == 1 else "s", cite(["awm", "trace"], order))) else: parts.append( "<p class='sub'>These signals recur in this repo's sessions but " "appear in none of its memory files — rules the user keeps " "dictating by hand, session after session. Each ships with " "receipts.%s</p>" % cite(["awm", "trace"], order)) tax = chart.get("startup_tax") or {} if tax.get("est_tokens"): parts.append( "<p class='sub'>Re-discovery tax: the recurring early discovery " "commands consumed ~<b>%s tokens</b> of transcript across " "%d session%s — re-deriving repo facts a memory file states once " "(occupancy estimate attributed to those commands' records and " "results).</p>" % ("{:,}".format(tax["est_tokens"]), tax.get("sessions", 0), "" if tax.get("sessions") == 1 else "s")) rows = ["<tr><th>Proposed line</th><th>Signal</th>" "<th>Evidence</th><th>Placement</th></tr>"] for kind, items in (("fact", facts), ("rule", rules)): for it in items: text = it.get("text", "") shown = text[:90] + ("…" if len(text) > 90 else "") n, s = it.get("occurrences", 0), it.get("sessions", 0) story = (it.get("provenance") or ("%d× across %d session%s" % (n, s, "" if s == 1 else "s") if n and s else "mined from history")) ev = "" samples = it.get("evidence") or [] if samples: blocks = "".join( "<div class='tl-sum'><b>%s</b>%s — <span class='mono'>%s</span></div>" % (esc(str(sm.get("session", ""))[:8]), " (turn %s)" % esc(sm["turn"]) if sm.get("turn") else "", esc(sm.get("excerpt", ""))) for sm in samples) ev = ("<details><summary>receipts</summary>%s</details>" % blocks) if kind == "fact": place = "<span class='pill p-info'>fact</span>" \ "<br><span class='sub'>prose — states it once</span>" else: cls = it.get("class") or "unclassified" place = ("<span class='pill %s'>%s</span><br><span class='sub'>%s</span>" % (CLASS_PILL.get(cls, "p-info"), esc(cls), esc(PLACEMENT_NOTE.get(cls, "prose")))) fam = it.get("family", "") rows.append( "<tr><td%s>%s%s</td><td><span class='pill p-warn'>%s</span></td>" "<td>%s</td><td>%s</td></tr>" % (" title='%s'" % esc(text) if len(text) > 90 else "", esc(shown), ev, esc(FAMILY_LABEL.get(fam, fam or "mined")), esc(story), place)) parts.append("<div class='tablebox'><table>%s</table></div>" % "".join(rows)) declined = chart.get("declined") or [] if declined: items = "".join("<li>%s — %s</li>" % (esc(d.get("text", "")), esc(d.get("reason", ""))) for d in declined) parts.append("<details><summary>%d candidate(s) declined on review</summary>" "<ul class='plain foot'>%s</ul></details>" % (len(declined), items)) parts.append("<p class='sub'>Draft (never auto-installed): <code>%s</code> — " "receipts ride in HTML comments, which Claude Code strips at " "load, so they cost the reviewer nothing.</p>" % esc(draft)) return "<section><h2>%s</h2>%s</section>" % (esc(title), "\n".join(parts)) def build_diagnoses(diagnosis, order): out = [] for d in (diagnosis or {}).get("diagnoses", []): loc = "" if d.get("file"): loc = "<span class='dx-loc'>%s%s</span>" % ( esc(os.path.basename(d["file"])), ":%s" % d["line"] if d.get("line") else "") ev = "" if d.get("evidence"): ev = ("<details><summary>evidence</summary><pre>%s</pre></details>" % esc("\n".join(d["evidence"]))) rx = "" if d.get("prescription"): rx = "<div class='rx'><b>💊 Prescription</b>%s</div>" % esc(d["prescription"]) out.append( "<div class='dx'><div class='dx-head'>" "<span class='pill %s'>%s</span>" "<span class='pill p-info'>%s</span>" "<span class='dx-title'>%s</span>%s%s</div>" "<p>%s</p>%s%s</div>" % (SEV_PILL.get(d.get("severity", "info"), "p-info"), esc(d.get("severity", "info")), esc(d.get("state", "")), esc(d.get("title", "")), loc, cite(d.get("citations"), order), esc(d.get("detail", "")), ev, rx)) return "\n".join(out) or "<p class='sub'>No diagnoses recorded.</p>" RX_EMOJIS = ["💊", "🩹", "💉", "🧪", "🌡️"] def build_prescriptions(diagnosis, order): items = [] for i, p in enumerate((diagnosis or {}).get("prescriptions", [])): items.append("<li><span class='pe'>%s</span><div><b>%s</b>%s<br>" "<span class='sub'>%s</span></div></li>" % (RX_EMOJIS[i % len(RX_EMOJIS)], esc(p.get("action", "")), cite(p.get("citations"), order), esc(p.get("rationale", "")))) return "<ul class='rxlist'>%s</ul>" % "".join(items) if items \ else "<p class='sub'>None beyond the per-diagnosis prescriptions.</p>" def main(): ap = argparse.ArgumentParser() ap.add_argument("--work", required=False) ap.add_argument("--out", default=None) ap.add_argument("--list-citations", action="store_true", help="print valid citation ids with what each source claims") args = ap.parse_args() if args.list_citations: for cid, (label, url) in CITATIONS.items(): print("%-24s %s\n%25s%s" % (cid, label, "", url)) return if not args.work: ap.error("--work is required (unless --list-citations)") work = args.work intake = load_json(os.path.join(work, "intake.json")) vitals = load_json(os.path.join(work, "vitals.json")) refcheck = load_json(os.path.join(work, "refcheck.json")) diagnosis = load_json(os.path.join(work, "diagnosis.json")) manifest = load_json(os.path.join(work, "manifest.json"), {"stages": []}) if not (intake and vitals): sys.exit("report: intake.json and vitals.json are required") done = {s["stage"] for s in manifest["stages"]} if diagnosis: done.add("diagnosis") missing = [s for s in REQUIRED_STAGES if s not in done] exam_note = "" if missing: exam_note = ("<div class='note'>Incomplete exam: stage(s) %s did not run. " "Findings below cover only the completed stages.</div>" % esc(", ".join(missing))) order = [] tpl_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "templates", "report.html") tpl = open(tpl_path, encoding="utf-8").read() backtest = load_json(os.path.join(work, "backtest.json")) rulebook = load_json(os.path.join(work, "rulebook.json")) chart = load_json(os.path.join(work, "chart.json")) grade = (diagnosis or {}).get("grade", "—") history_fallback = (diagnosis or {}).get("history_note") or ( "Not examined in this run. %d session transcript(s) were located for " "this repo — the backtest replays each rule against them." % intake.get("sessions", {}).get("session_files", 0)) followup = "".join("<li><span class='pe'>🩺</span><div>%s</div></li>" % esc(f) for f in (diagnosis or {}).get("followup", [])) repl = { "{{REPO_NAME}}": esc(os.path.basename(intake["repo"])), "{{EXAM_DATE}}": time.strftime("%Y-%m-%d %H:%M"), "{{VERSION}}": VERSION, "{{GRADE}}": esc(grade), "{{HEARTS}}": build_hearts(grade), "{{GRADE_CLASS}}": (grade[:1].lower() if grade and grade[0].isalpha() else "c"), "{{CHIEF_COMPLAINT}}": esc((diagnosis or {}).get( "chief_complaint", "No model diagnosis pass was recorded.")), "{{EXAM_NOTE}}": exam_note, "{{PATIENT_ROWS}}": build_patient(intake, vitals, backtest), "{{VITALS_CARDS}}": build_vitals(vitals, order), "{{FILES_TABLE}}": build_files_table(vitals), "{{LAB_HTML}}": build_lab(refcheck, diagnosis, order), "{{RULEBOOK_HTML}}": build_rulebook( rulebook, backtest, diagnosis, work, order, history_fallback, mined=bool(chart and chart.get("mode") == "intake")), "{{CHART_HTML}}": build_chart(chart, work, order), "{{DIAGNOSES_HTML}}": build_diagnoses(diagnosis, order), "{{PRESCRIPTIONS_HTML}}": build_prescriptions(diagnosis, order), "{{FOLLOWUP_HTML}}": "<ul class='rxlist'>%s</ul>" % ( followup or "<li><span class='pe'>🩺</span>" "<div>Re-run after applying prescriptions.</div></li>"), } footnotes = "".join( '<li id="fn-%s">%s <a href="%s">%s</a></li>' % (cid, esc(CITATIONS[cid][0]), esc(CITATIONS[cid][1]), esc(CITATIONS[cid][1])) for cid in order) repl["{{FOOTNOTES_HTML}}"] = footnotes or "<li>No citations referenced.</li>" for k, v in repl.items(): tpl = tpl.replace(k, v) out_path = args.out or os.path.join(os.path.dirname(work), "report.html") os.makedirs(os.path.dirname(out_path), exist_ok=True) with open(out_path, "w", encoding="utf-8") as f: f.write(tpl) save_json(os.path.join(os.path.dirname(work), "report.json"), {"version": VERSION, "repo": intake["repo"], "generated": time.strftime("%Y-%m-%dT%H:%M:%S%z"), "grade": grade, "vitals": vitals, "refcheck_stats": (refcheck or {}).get("stats"), "diagnosis": diagnosis, "backtest": backtest, "chart": chart, "incomplete_stages": missing}) manifest_add(work, "report", out=out_path, incomplete=missing) print("report: wrote %s%s" % (out_path, " (INCOMPLETE: missing %s)" % ",".join(missing) if missing else "")) if __name__ == "__main__": main() -
sessions.py 8.6 KB
#!/usr/bin/env python3 """Stage 4a — sessions: condense this repo's Claude Code transcripts. Reads ~/.claude/projects/<slug>/*.jsonl (located by intake) and reduces each multi-MB transcript to a compact event stream the backtest can replay: user text, assistant text, every tool call with its meaningful inputs, and tool errors. Sidecar records and tool_result bodies are dropped. Parsing is defensive — the transcript format is undocumented and aborted stub sessions exist in the wild. Usage: python3 sessions.py --work DIR [--max-sessions N] [--dir OVERRIDE] Reads: <work>/intake.json Writes: <work>/sessions/<id>.json + sessions_index.json """ import argparse import json import os import sys sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) from _common import load_json, manifest_add, save_json TEXT_CAP = 400 NEW_CONTENT_CAP = 1200 def _head(s, cap): s = s if isinstance(s, str) else json.dumps(s, ensure_ascii=False) return s if len(s) <= cap else s[:cap] + "…" def condense_file(path): """One transcript -> (events, meta). Never raises on bad lines. Every event carries `off`, its byte offset in the raw transcript — a context-occupancy proxy (tool_result bytes count toward context even though their bodies are dropped here). Compaction boundaries (`system` records with compactMetadata, or user records with isCompactSummary) are emitted as {"t": "compact"} events so the backtest can bucket violations by context state (fresh / diluted / post-compact).""" events, turns, first_ts, last_ts = [], 0, None, None offset = 0 try: fh = open(path, "rb") except OSError: return [], {} with fh: for raw in fh: line_off, offset = offset, offset + len(raw) try: rec = json.loads(raw.decode("utf-8", "replace")) except ValueError: continue if not isinstance(rec, dict): continue ts = rec.get("timestamp") if ts: first_ts, last_ts = first_ts or ts, ts rtype = rec.get("type") if (rtype == "system" and "compactMetadata" in rec) or \ (rtype == "user" and rec.get("isCompactSummary")): events.append({"t": "compact", "turn": turns, "ts": ts, "off": line_off}) continue if rec.get("isMeta") or rec.get("isSidechain"): continue # injected skill/command bodies, subagent records — # they wear type:"user" but are not the human msg = rec.get("message") or {} content = msg.get("content") n_before = len(events) if rtype == "user": # origin.kind (newer transcripts): "human" = actually typed src = (rec.get("origin") or {}).get("kind") if isinstance(content, str): turns += 1 ev = {"t": "user", "turn": turns, "ts": ts, "text": _head(content, TEXT_CAP)} if src: ev["src"] = src events.append(ev) elif isinstance(content, list): texts = [b.get("text", "") for b in content if isinstance(b, dict) and b.get("type") == "text"] if texts: turns += 1 ev = {"t": "user", "turn": turns, "ts": ts, "text": _head("\n".join(texts), TEXT_CAP)} if src: ev["src"] = src events.append(ev) for b in content: if isinstance(b, dict) and b.get("type") == "tool_result" \ and b.get("is_error"): ev = {"t": "tool_error", "turn": turns, "ts": ts, "text": _head(b.get("content", ""), 200)} # user-rejected | permission-rule | automode-blocked if rec.get("toolDenialKind"): ev["denial"] = rec["toolDenialKind"] # ties the error to its tool call even when the # assistant batched several calls in one turn if b.get("tool_use_id"): ev["for_id"] = b["tool_use_id"][-8:] events.append(ev) elif rtype == "assistant" and isinstance(content, list): for b in content: if not isinstance(b, dict): continue btype = b.get("type") if btype == "text" and b.get("text", "").strip(): events.append({"t": "assistant", "turn": turns, "ts": ts, "text": _head(b["text"], TEXT_CAP)}) elif btype == "tool_use": name = b.get("name", "?") inp = b.get("input") or {} ev = {"t": "tool", "turn": turns, "ts": ts, "name": name} if b.get("id"): ev["id"] = b["id"][-8:] if name == "Bash": ev["command"] = _head(inp.get("command", ""), 600) elif name in ("Edit", "Write", "MultiEdit", "NotebookEdit"): ev["file_path"] = inp.get("file_path", "") new = inp.get("new_string") or inp.get("content") or "" if name == "MultiEdit": new = "\n".join(e.get("new_string", "") for e in inp.get("edits", []) if isinstance(e, dict)) ev["new"] = _head(new, NEW_CONTENT_CAP) else: ev["input_keys"] = sorted(inp.keys())[:8] events.append(ev) # every other record type (attachment, permission-mode, ai-title, # file-history-snapshot, system, …) is a sidecar: skipped for ev in events[n_before:]: ev.setdefault("off", line_off) return events, {"turns": turns, "first_ts": first_ts, "last_ts": last_ts, "total_bytes": offset, "compactions": len([e for e in events if e["t"] == "compact"])} def main(): ap = argparse.ArgumentParser() ap.add_argument("--work", required=True) ap.add_argument("--max-sessions", type=int, default=30) ap.add_argument("--dir", default=None, help="override sessions directory") args = ap.parse_args() intake = load_json(os.path.join(args.work, "intake.json")) if not intake: sys.exit("sessions: run intake.py first (missing intake.json)") sdir = args.dir or (intake.get("sessions") or {}).get("dir") out_dir = os.path.join(args.work, "sessions") index = {"dir": sdir, "sessions": []} if not sdir or not os.path.isdir(sdir): save_json(os.path.join(args.work, "sessions_index.json"), index) manifest_add(args.work, "sessions", sessions=0, note="no history found") print("sessions: no transcript directory found for this repo") return files = sorted((os.path.join(sdir, f) for f in os.listdir(sdir) if f.endswith(".jsonl")), key=lambda p: os.path.getmtime(p), reverse=True) kept = 0 for path in files[:args.max_sessions]: sid = os.path.splitext(os.path.basename(path))[0] events, meta = condense_file(path) tools = len([e for e in events if e["t"] == "tool"]) if not events: index["sessions"].append({"id": sid, "events": 0, "tools": 0, "skipped": "empty/stub"}) continue save_json(os.path.join(out_dir, sid + ".json"), events) entry = {"id": sid, "events": len(events), "tools": tools, "raw_bytes": os.path.getsize(path)} entry.update(meta) index["sessions"].append(entry) kept += 1 save_json(os.path.join(args.work, "sessions_index.json"), index) manifest_add(args.work, "sessions", sessions=kept, skipped=len(index["sessions"]) - kept) total_tools = sum(s.get("tools", 0) for s in index["sessions"]) print("sessions: condensed %d session(s) (%d stubs skipped), %d tool calls -> %s" % (kept, len(index["sessions"]) - kept, total_tools, out_dir)) if __name__ == "__main__": main() -
vitals.py 6.6 KB
#!/usr/bin/env python3 """Stage 2 — vitals: size, structure, and pathology markers for each memory file. Official thresholds (cited in the report): 200 lines per file (memory doc), 40,000-char combined startup warning, 4 MiB per-file hard skip. Effective lines are measured after stripping block HTML comments, matching Claude Code's loader. Usage: python3 vitals.py --work DIR Reads: <work>/intake.json Writes: <work>/vitals.json """ import argparse import os import re import sys sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) from _common import (est_tokens, iter_lines, load_json, manifest_add, read_text, save_json, strip_html_comments) SIZE_TARGET_LINES = 200 # official: memory doc STARTUP_WARN_CHARS = 40_000 # official: troubleshooting doc HARD_SKIP_BYTES = 4 * 1024 * 1024 # official: memory doc CONTEXT_BUDGET_TOKENS = 200_000 # typical window, for the %-of-context vital EMPHASIS_RE = re.compile(r"\b(NEVER|ALWAYS|IMPORTANT|CRITICAL|MUST|DO NOT|DON'T)\b") BOLD_RE = re.compile(r"\*\*[^*\n]+\*\*") INIT_BOILERPLATE_RE = re.compile(r"provides guidance to Claude Code", re.I) DATE_RE = re.compile( r"\b(20\d{2}-\d{2}(-\d{2})?|" r"(Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)[a-z]*\.?\s+20\d{2})\b") IMPERATIVE_RE = re.compile( r"^\s*(?:[-*]\s+)?(Use|Run|Never|Always|Do|Don't|Avoid|Prefer|Keep|Add|" r"Check|Read|Write|Test|Set|Follow|Ask|Stop|Only|Wrap|Put|See)\b", re.I) def heading_stats(text): heads, stack = [], [] for lineno, line, in_fence in iter_lines(text): if in_fence: continue m = re.match(r"^(#{1,6})\s+(.*)$", line) if m: heads.append({"level": len(m.group(1)), "text": m.group(2).strip(), "line": lineno}) dupes = {} for h in heads: dupes.setdefault(h["text"].lower(), []).append(h["line"]) duplicate_headings = [{"text": t, "lines": ls} for t, ls in dupes.items() if len(ls) > 1] # longest section = gap between consecutive headings longest = None total_lines = text.count("\n") + 1 for i, h in enumerate(heads): end = heads[i + 1]["line"] - 1 if i + 1 < len(heads) else total_lines span = end - h["line"] if longest is None or span > longest["lines"]: longest = {"heading": h["text"], "start_line": h["line"], "lines": span} return heads, duplicate_headings, longest def measure(path): raw = read_text(path) if raw is None: return None clean, comment_lines_removed = strip_html_comments(raw) lines = clean.splitlines() eff_lines = len([l for l in lines if l.strip()]) chars = len(clean) heads, dupes, longest = heading_stats(clean) emphasis_lines = bold_lines = dated_lines = imperative_lines = fence_lines = 0 in_prose_lines = 0 for _, line, in_fence in iter_lines(clean): if in_fence: fence_lines += 1 continue if not line.strip(): continue in_prose_lines += 1 if EMPHASIS_RE.search(line): emphasis_lines += 1 if BOLD_RE.search(line): bold_lines += 1 if DATE_RE.search(line): dated_lines += 1 if IMPERATIVE_RE.match(line) or re.search(r"\b(must|should|never|always)\b", line, re.I): imperative_lines += 1 head_text = "\n".join(clean.splitlines()[:5]) per100 = (lambda n: round(100.0 * n / eff_lines, 1) if eff_lines else 0.0) return { "raw_lines": raw.count("\n") + 1, "effective_lines": eff_lines, "comment_lines_removed": comment_lines_removed, "chars": chars, "est_tokens": est_tokens(chars), "size_bytes": len(raw.encode("utf-8", "replace")), "over_size_target": eff_lines > SIZE_TARGET_LINES, "over_hard_skip": len(raw.encode("utf-8", "replace")) > HARD_SKIP_BYTES, "headings": len(heads), "max_heading_level": max([h["level"] for h in heads], default=0), "duplicate_headings": dupes, "longest_section": longest, "code_fence_lines": fence_lines, "emphasis_lines": emphasis_lines, "emphasis_per_100_lines": per100(emphasis_lines), "bold_lines": bold_lines, "dated_lines": dated_lines, "dated_per_100_lines": per100(dated_lines), "imperative_lines": imperative_lines, "imperative_ratio": round(imperative_lines / in_prose_lines, 2) if in_prose_lines else 0.0, "init_boilerplate": bool(INIT_BOILERPLATE_RE.search(head_text)), } def main(): ap = argparse.ArgumentParser() ap.add_argument("--work", required=True) args = ap.parse_args() intake = load_json(os.path.join(args.work, "intake.json")) if not intake: sys.exit("vitals: run intake.py first (missing intake.json)") per_file, combined = {}, {"effective_lines": 0, "chars": 0, "est_tokens": 0} for rec in intake["files"]: if not rec["exists"] or rec["excluded"]: continue m = measure(rec["path"]) if m is None: continue m["scope"] = rec["scope"] m["loaded_at_launch"] = rec["loaded_at_launch"] m["external"] = rec["external"] m["is_pointer"] = rec.get("is_pointer", False) per_file[rec["path"]] = m if rec["path"] in intake["effective_launch_loaded"]: for k in combined: combined[k] += m[{"effective_lines": "effective_lines", "chars": "chars", "est_tokens": "est_tokens"}[k]] combined["pct_of_context"] = round( 100.0 * combined["est_tokens"] / CONTEXT_BUDGET_TOKENS, 2) combined["startup_warning"] = combined["chars"] > STARTUP_WARN_CHARS out = { "thresholds": {"size_target_lines": SIZE_TARGET_LINES, "startup_warn_chars": STARTUP_WARN_CHARS, "hard_skip_bytes": HARD_SKIP_BYTES, "context_budget_tokens": CONTEXT_BUDGET_TOKENS}, "per_file": per_file, "launch_loaded_combined": combined, } save_json(os.path.join(args.work, "vitals.json"), out) manifest_add(args.work, "vitals", files_measured=len(per_file)) print("vitals: measured %d files; launch-loaded total %d effective lines, " "~%d tokens (%.2f%% of a %dk context) -> vitals.json" % (len(per_file), combined["effective_lines"], combined["est_tokens"], combined["pct_of_context"], CONTEXT_BUDGET_TOKENS // 1000)) if __name__ == "__main__": main() -
_common.py 4.4 KB
"""Shared helpers for claude-md-doctor scripts. Stdlib only; Python 3.9+.""" import hashlib import json import os import re import time FENCE_RE = re.compile(r"^\s*(```|~~~)") INLINE_CODE_RE = re.compile(r"`[^`\n]*`") def read_text(path): try: with open(path, "r", encoding="utf-8", errors="replace") as f: return f.read() except OSError: return None def sha1_of(text): return hashlib.sha1(text.encode("utf-8", "replace")).hexdigest()[:12] def iter_lines(text): """Yield (lineno, line, in_fence) with fenced-code tracking (``` / ~~~).""" in_fence = False fence_marker = None for i, line in enumerate(text.splitlines(), start=1): m = FENCE_RE.match(line) if m: marker = m.group(1) if not in_fence: in_fence, fence_marker = True, marker yield i, line, True continue if marker == fence_marker: yield i, line, True in_fence, fence_marker = False, None continue yield i, line, in_fence def strip_inline_code(line): """Replace `code spans` with spaces (preserves indices loosely).""" return INLINE_CODE_RE.sub(lambda m: " " * len(m.group(0)), line) def strip_html_comments(text): """Remove <!-- --> blocks outside code fences (Claude Code strips these before injection). Returns (clean_text, removed_line_count).""" out, removed = [], 0 in_comment = False for _, line, in_fence in iter_lines(text): if in_fence: out.append(line) continue buf = line keep = "" while buf: if in_comment: end = buf.find("-->") if end == -1: buf = "" else: buf = buf[end + 3:] in_comment = False else: start = buf.find("<!--") if start == -1: keep += buf buf = "" else: keep += buf[:start] buf = buf[start + 4:] in_comment = True if keep.strip() or (not line.strip()): out.append(keep) else: removed += 1 return "\n".join(out), removed def parse_frontmatter(text): """Minimal YAML frontmatter parser: returns (meta_dict, body). Supports `key: value` and `key:` + `- item` lists. Not a YAML parser.""" lines = text.splitlines() if not lines or lines[0].strip() != "---": return {}, text meta, i, current_key = {}, 1, None while i < len(lines): line = lines[i] if line.strip() == "---": return meta, "\n".join(lines[i + 1:]) item = re.match(r"^\s+-\s+(.*)$", line) kv = re.match(r"^([A-Za-z0-9_-]+):\s*(.*)$", line) if item and current_key: meta.setdefault(current_key, []) if isinstance(meta[current_key], list): meta[current_key].append(item.group(1).strip().strip("\"'")) elif kv: key, val = kv.group(1), kv.group(2).strip() if val == "": meta[key] = [] current_key = key else: meta[key] = val.strip("\"'") current_key = None i += 1 return {}, text # unterminated frontmatter: treat as body def load_json(path, default=None): try: with open(path, "r", encoding="utf-8") as f: return json.load(f) except (OSError, ValueError): return default def save_json(path, data): os.makedirs(os.path.dirname(path), exist_ok=True) with open(path, "w", encoding="utf-8") as f: json.dump(data, f, indent=2, ensure_ascii=False) f.write("\n") def manifest_add(work_dir, stage, **info): """Append a stage record to the exam's work-state manifest. The report verifies this so a silently skipped stage is visible (SIGIL principle).""" path = os.path.join(work_dir, "manifest.json") manifest = load_json(path, default={"stages": []}) manifest["stages"] = [s for s in manifest["stages"] if s.get("stage") != stage] record = {"stage": stage, "ts": time.strftime("%Y-%m-%dT%H:%M:%S%z")} record.update(info) manifest["stages"].append(record) save_json(path, manifest) def est_tokens(chars): """Crude estimate (~4 chars/token). Labeled as an estimate everywhere.""" return int(round(chars / 4.0))
-
-
templates
-
report.html 10 KB · in bundle
-
-
SKILL.md 24.7 KB
--- name: claude-md-doctor description: Give this repo's CLAUDE.md / AGENTS.md a checkup — size vitals vs official guidance, dead references, dead commands, stale claims — then backtest every rule against the repo's own Claude Code session history to see which rules were actually followed, ignored, or never used, and produce a doctor-style HTML report with evidence-cited prescriptions. Use when asked to check, diagnose, audit, review, improve, optimize, lint, grade, fix, clean up, shorten, or "doctor" CLAUDE.md, AGENTS.md, or agent instruction/memory files, or to find out whether CLAUDE.md rules actually work. Also use when a repo has NO memory file and the user wants one — "write/generate/suggest a CLAUDE.md (or hooks) from my sessions" — the skill mines the repo's real session history and drafts a proposed file with receipts. argument-hint: "[repo path] [--include-user]" --- # CLAUDE.md Doctor — exam procedure You are running a checkup on this repository's agent-instruction files. The deterministic work lives in scripts; your job is the judgment between them. Do not re-derive what a script already measured, and do not skip a stage — the report verifies the work-state manifest and will disclose skipped stages. Definitions used below: - `SCRIPTS` = `${CLAUDE_SKILL_DIR}/scripts` — Claude Code substitutes `${CLAUDE_SKILL_DIR}` with this skill's own directory at run time, so the scripts resolve no matter the working directory or how the skill was installed. Use it literally; do not try to locate the skill yourself. - `REPO` = the repository to examine (the argument if one was given, else the current working directory). - `WORK` = `REPO/.claude-md-doctor/work` (scripts default to this; pass `--work` to relocate, e.g. into a scratch directory to avoid writing in the user's repo — prefer that when the repo is not yours to dirty). ## Stage 0 — preflight Run `python3 --version`. If python3 is missing, stop and tell the user this skill needs Python 3.9+ (stdlib only, nothing to install). ## Stage 1 — intake python3 SCRIPTS/intake.py --repo REPO --work WORK Then read `WORK/intake.json` (it is small). Note for later judgment: - Is the project CLAUDE.md a **pointer** (`is_pointer`) to AGENTS.md? That is the healthy, officially-recommended pattern — the target is the patient. Never diagnose a pointer file as "too short." Check `pointer_style`: `symlink` and `import` work; **`bare-text` is a broken pointer** — a regular file containing just `AGENTS.md` without `@` means Claude Code never loads the target. That is a critical diagnosis with a one-character fix (`@`), unless you are examining a raw-fetched copy where symlinks flatten to text. - Ancestor and user-scope files are context the session loads but the repo can't fix — mention them, don't prescribe changes to them unless asked. - A file with scope `orphan-agents` means the repo has an AGENTS.md but no CLAUDE.md pointing at it — **Claude Code loads nothing**. That is a critical diagnosis with the official one-line fix (create a CLAUDE.md containing `@AGENTS.md`), and you should still run the full static exam on the AGENTS.md itself, since it becomes the patient the moment the pointer exists. - If NO memory files exist at all, do not stop with an empty report — switch to **Mode B** (below): mine the session history and write the initial chart. Only when there is no session history either does the exam end, with the `/init`-plus-aggressive-pruning prescription and a note saying why nothing else could run. ## Stage 2 — vitals python3 SCRIPTS/vitals.py --work WORK Read `WORK/vitals.json`. The script measured; you interpret. Detector notes: - `init_boilerplate` means the file still opens with stock `/init` output — a generated-and-never-pruned marker. - `emphasis_per_100_lines` matters as **density**, not presence (sparse emphasis is officially endorsed). - High `dated_per_100_lines` suggests session-log/changelog accretion. - Very low `imperative_ratio` on a large file suggests narrative documentation rather than instructions — read a sample and judge; the arcan case (a CLAUDE.md containing a sabotage manual) is why this check exists. - **Judge the aggregate surface, not only each file** (`launch_loaded_combined` plus the file count): many individually-healthy files can still sum to a heavy standing context, and cross-file duplication or contradiction is invisible per-file. When the combined surface is the problem, prescribe the escalating ladder — consolidate duplicates, then a thin router/index over on-demand files, then a one-screen always-on invariants file with procedures moved to skills (citation id `surface-bloat`). ## Stage 3 — records check python3 SCRIPTS/refcheck.py --work WORK Read `WORK/refcheck.json`. Your judgment passes: 1. **Review the failures, don't parrot them.** For each `missing` / `machine_specific` / `glob_empty` reference and each `missing` command, open the cited file:line and confirm it is a real reference (not prose that merely looks like a path — API endpoints, MIME types, git refs, and files the text describes as deleted are the common false positives). Record each false positive in `dismissed_refs` with its reason: the report shows only confirmed findings and discloses dismissals in a collapsed note. 2. **Extract checkable claims** the scripts cannot: countable assertions in the memory files ("3,540 tests across 374 files", "12 UI components", "there is no ESLint config"). Verify the cheap ones with quick commands (file counts, grep for configs). Do NOT run test suites or builds unless the user asked. Record each as `verified` / `drifted` / `unverified` with a one-line detail — `unverified` is an honest answer for anything expensive. ## Stage 4 — history backtest Skip this stage only if intake found no session directory (`sessions.dir` null) — and then say so in chat; the report's History section will state it. ### 4a — condense the transcripts python3 SCRIPTS/sessions.py --work WORK ### 4b — decompose the memory files into a rulebook (your judgment) Write `WORK/rulebook.json` (schema documented at the top of `backtest.py`). Guidance: - **Decompose EVERY directive in the file** — the rulebook is the complete directive inventory, and the enforcement ladder's "N of M" is only honest if M is the whole file. Only **mechanically checkable** rules get matchers: bans and requirements visible in Bash commands or Edit/Write content, and finish-ordering rules via `ordering`. Judge-class and not-yet-mechanizable rules go in as **classification-only entries** (enforcement block, no matchers) — never force a regex onto a semantic rule. Informational content (facts, architecture, API semantics) stays OUT of the rulebook. - For edit/write events the matchable text is `PATH: <file_path>` on the first line followed by the (truncated) new content — anchor path-based rules on `^PATH: .*…` and content rules on the body. - Write **conservative** regexes (prefer false negatives over false positives), use `scope.paths` / `scope.exclude_paths` to confine file-scoped rules, and date each rule with `introduced` from `git log --follow --format=%aI -- <file>` when the file's history makes that cheap — sessions that ended before a rule existed must not count against adherence. - **Classify every rule's enforcement** (the `enforcement` block — schema at the top of `backtest.py`). Split compound rules into clauses first; each clause classifies independently. The class is the cheapest reliable detector: `hook` (event-stream regex: bash/edit/path/tool-input/output gates, ordering, cadence — try the event-ordering and standing-invariant reframings BEFORE surrendering a rule to judge), `linter`/`test` (static analysis over artifacts: lint rules, discipline tests, import-graph boundaries — record `scope_kind: file|project`), or `judge` (only an LLM can score it). A rule even a judge couldn't score is not a rule — diagnose it `vague`. Detect **existing enforcement**: if the repo already has the test/lint/hook the prose describes, set `current_layer` to it — that rule is a healthy pointer, never a prescription target. `current_layer` may also be an **org-level rule platform** (team-wide rulebooks with centralized detectors/telemetry) — the right home for cross-repo rules, judge-class auditing at scale, and staged warn→block rollouts that per-repo configs can't govern. Give every classified rule an `echo_regex` of its distinctive tokens (for proven-defiance detection) and an `origin` (root/nested/rules — only non-root rules can be truly absent after compaction). Also judge `against_prior: true|false` in the enforcement block: would a frontier model do this by default WITHOUT the rule? A with-prior rule showing high compliance may be coincidence, not obedience (citation id `harness-if`) — flag it as a redundancy candidate in diagnosis rather than celebrating it as healthy. And when prescribing move-to-skill: that move is for *procedures* only — a *constraint* demoted into a skill description measurably loses precedence (project files outrank tool/skill descriptions). Engine semantics you need (so you don't reverse-engineer them): - **"Opportunities"** = matcher fires (violation+compliance+context hits) for regex rules, and mutated-session count for ordering rules. Zero can mean "rule never applied" OR "your scope is wrong" — for any zero-fire path-scoped rule, run one **negative control** (confirm the sessions contain no events under that scope at all) before calling it inert. - `scope.paths` filters only events that carry a file path; **bash events pass a paths filter** (they have no path) — for mixed bash+edit rules put path constraints into the regex (`^PATH: …`) if bash must be excluded. - `exclude_paths` and `repo_only` DO apply to ordering-rule mutation counting. - Edit/Write matchable content is **truncated to ~1200 chars** of new content (bash commands ~600) — first-line rules are fine; end-of-file or size rules are not expressible as content regexes. - Condensed sessions are a **top-level JSON array** of event objects. - **Read-before-edit ordering is NOT yet expressible** (`ordering.require` matches bash commands only) — classify such rules as unmechanized hooks; don't torture a regex. ### 4c — run the engine python3 SCRIPTS/backtest.py --work WORK ### 4d — sample-verify (MANDATORY — matchers have bugs) Read `WORK/backtest.json`. For EVERY rule with fires — violation AND compliance samples both — read the sample excerpts and confirm each is a true positive. A matcher with any false positive gets fixed in `rulebook.json` and the engine re-run — this loop is cheap and it is the whole reason the results can be trusted. Only when every sampled fire is confirmed, set `"verified": true` in `backtest.json` (edit the file) — the report shows a "provisional" banner otherwise. **Mention is not use.** The most common false positive is a session that *talks about* a rule rather than breaking it: documenting the hazard, grepping for offenders, writing the rule itself, quoting it in a commit message or a retraction. A regex cannot tell discussion from violation, and these land as `defiance-proven` — the most severe cause — because the rule text is echoed right there. When a repo's own docs quote its rules, expect this and check the excerpt for whether the event *performed* the banned action or merely referred to it. Repos that document their own conventions generate this heavily; drop those fires and tighten the matcher (anchor on the action, exclude edits to the memory files and docs via `scope.exclude_paths`). Then record per-rule verdicts in `diagnosis.json` under `rule_verdicts`: ```json "rule_verdicts": { "R1": {"verdict": "healthy|ignored|mixed|inert", "note": "one line of judgment"} } ``` (That is the common subset — Stage 5's schema is canonical and adds `unmeasured|abandoned|undocumented`; mined rules take `undocumented`.) `inert` (zero opportunities in the window) is a finding, not a failure — say what it means: the rule cost context in every session and never came up. The engine also triages every violation by **cause**: `defiance-proven` (the agent echoed the rule in its own text, then violated it — the reminder already happened and lost), `defiance` (fresh context), `dilution` (late turn / heavy context), `absence-risk` (non-root rule after a compaction boundary). Read the causes before judging: they pick the medicine — proven defiance justifies block-mode; dilution calls for slimming/path-scoping, not cages; absence calls for re-injection hooks. Sanity-check the buckets while sample-verifying (a "dilution" tag on a turn-2 violation means the occupancy proxy misfired — say so). Ordering-rule caveat: verdicts are per-transcript — in subagent/worktree workflows the required command may have run in a sibling transcript. A conversation message *claiming* it ran ("verify green") is not proof; note the claim in your verdict and check whether repo edits happened after it (the obligation re-ripens). ### 4e — compile enforcement proposals python3 SCRIPTS/compile.py --work WORK This writes `WORK/enforcement/` — a PROPOSALS.md dossier per rule, a generic guard script, its per-rule config (warn-mode by default; defiance-proven rules start at block), and a settings snippet. **Never install any of it yourself; never edit the user's `.claude/settings.json`.** Goodhart caution (citation id `specbench`): a visible pattern-gate can be satisfied without honoring the rule — where a rule has a real outcome (tests pass, build green), prefer a gate that runs the outcome over one that greps a pattern. Tell the user where the proposals live and that they are review-then-arm. ### 4f — gap analysis: what the sessions dictate that the file never says python3 SCRIPTS/mine.py --work WORK Read `WORK/candidates.json` and compare the surviving groups against the rulebook: a recurrent signal (correction cluster, failed→fixed command pair, recurring user denial) that matches NO existing rule is a rule the user keeps dictating by hand, session after session. Judge as in Mode B2 below — decline one-offs and `stale` groups (automode blocks are already excluded by the miner). For each accepted miss, add a diagnosis with state `undocumented` (severity `warn`, evidence = 1–2 excerpts), and when there are enough to matter, write `WORK/chart.json` with `"mode": "gap"` (schema at the top of `generate.py`) and run `python3 SCRIPTS/generate.py --work WORK` to emit `PROPOSED-ADDITIONS.md`. Mind the combined budget: proposed additions must not push the surface past the size target this same exam just graded. ## Stage 5 — diagnosis (your judgment, written to a file) Write `WORK/diagnosis.json`: ```json { "grade": "B", "chief_complaint": "One sentence, doctor-voice, the single biggest issue.", "history_note": "optional override for the History section", "stale_claims": [ {"claim": "…", "file": "/abs/path", "line": 12, "status": "verified|drifted|unverified", "detail": "…"} ], "dismissed_refs": [ {"ref": "the exact ref string from refcheck.json", "line": 46, "reason": "why it is a false positive (route not file, MIME type, described as deleted, …)"} ], "rule_verdicts": { "R1": {"verdict": "healthy|ignored|mixed|inert|unmeasured|abandoned|undocumented", "note": "one line of judgment; 'abandoned' = the repo's own history contradicts the rule (e.g. git shows the team doing the banned thing routinely) even if sessions were inert"} }, "diagnoses": [ {"state": "dead-ref|stale|vague|ignored|inert|redundant|contradictory|oversized|accretion|generated-unpruned|undocumented", "severity": "critical|warn|info", "title": "short name", "detail": "1–3 sentences, plain language", "file": "/abs/path", "line": 46, "evidence": ["short quoted lines or metric readouts"], "citations": ["official-200"], "prescription": "the concrete fix, imperative voice"} ], "prescriptions": [ {"action": "repo-wide action", "rationale": "why", "citations": ["eth"]} ], "followup": ["re-run cadence; transcript-retention advice; what to fix first"], "share_note": "one quotable line for the public share card — dry doctor's wit backed by the findings. STRICT safety: no file paths, no rule text, no quotes from the repo, no session ids, nothing repo-identifying; aggregate truths only (e.g. 'The loudest rule was the broken one.'). Omit the field to use a deterministic fallback." } ``` Rules for this stage: - **Every diagnosis needs evidence** (a quoted line, a metric, a failed check) and, where one exists, a citation id. List the valid ids and what each source claims with `python3 SCRIPTS/report.py --list-citations` — use only those ids, and only where the source actually supports the point. A check with no official or research backing is stated as a heuristic in its `detail`. - **Severity honestly**: `critical` = the file lies to the agent (dead refs, drifted claims, contradictions) or content is being skipped (4 MiB); `warn` = costs context or reduces adherence (oversized, emphasis saturation, accretion); `info` = worth knowing. - **Structure-only findings carry a caution**: the one factorial study found no structural effect in its tested range (citation id `mcmillan`) — do not present size/position folklore as causal fact. Content findings (dead refs, drift) need no such hedge. - **Grade rubric**: A = no criticals and at most 2 warns; B = no criticals and 3 or more warns; C = 1–2 criticals; D = 3+ criticals; F = the file is actively misleading (mostly dead/drifted) or unloadable. A pointer-style CLAUDE.md with a healthy target grades on the target. - Cannot-fix scopes (ancestor/user/managed files) may generate `info` diagnoses only. - **Never mention this tool's version numbers in report content** (diagnoses, notes, follow-ups, chief complaint). The renderer stamps the version in the report footer; content reads timelessly — a reader doesn't know or care what "v0.2" means. - **Pointer repos are usually cross-agent repos.** When the patient is an AGENTS.md reached via a pointer, it likely serves Cursor/Codex/Copilot too — prescriptions that relocate content into Claude-only surfaces (`.claude/rules/`, skills, hooks) hide it from those agents. Still prescribe them when right, but state the trade-off in the prescription ("Claude-only; other agents reading AGENTS.md will lose this") and prefer in-file fixes for content every agent needs. ## Stage 6 — report python3 SCRIPTS/report.py --work WORK Then generate the share-safe card and badge: python3 SCRIPTS/card.py --work WORK `card.svg` (postable checkup card) and `claude-md-health.svg` (README badge) land next to report.html. Both are aggregate-only by construction — but eye the card once anyway before telling the user it is safe to post. Offer the badge snippet the script prints for their README. Open or send the resulting `report.html` to the user, and summarize in chat: grade, chief complaint, the top 3 findings, and the single highest-value prescription. Tell the user where the report lives. If report.py printed an INCOMPLETE warning, say which stage was missing and why. ## Mode B — the chart-less patient (no memory file? mine one) Route here when intake found NO memory files at all, or when the user asked to *generate* a CLAUDE.md / hooks / lint suggestions from their history — but if the repo already HAS a memory file, never run Mode B: run the normal exam and satisfy the generate request through Stage 4f (gap analysis, `"mode": "gap"` → PROPOSED-ADDITIONS.md), so the existing file stays the patient and the intake framing ("no memory file exists") stays true. The transcripts already contain the unwritten rulebook: corrections the user keeps typing, commands that fail until the right one runs, facts re-derived at every session start, tool calls the user rejects. Mode B takes a history and writes the initial chart. Run Stages 2 and 3 first anyway — on an empty surface they finish instantly, keep the manifest honest, and "0 tokens loaded every session" is the patient's baseline vital. ### B1 — condense and mine python3 SCRIPTS/sessions.py --work WORK python3 SCRIPTS/mine.py --work WORK `candidates.json` holds mechanically pre-filtered signals in five families (corrections, failure_recovery, rediscovery, denials, preambles) plus a startup-tax estimate. The lexical markers are calibrated to ~65–75% precision — YOU are the judge pass; nothing in this file is a rule yet. ### B2 — judge the candidates (your judgment) Triage every entry: - **A rule** states something durable the user would still endorse: repeated corrections that converge ("use pnpm", "never push directly"), failed→fixed pairs whose fix is systematic (wrong runner, wrong dir, missing env var), recurring `user-rejected`/`permission-rule` denials. Write it as one imperative line. - **A fact** is repo knowledge the agent keeps re-deriving: build/test commands from rediscovery groups, layout/context from preamble clusters. - **Decline** one-off taste, task-specific instructions, anything flagged `stale` (the repo may have moved past it), and excerpts you cannot confidently generalize. (Auto-mode classifier blocks are already excluded by the miner — they appear only as `meta.automode_blocked`; do not resurrect them.) Record notable declines with reasons — the report discloses them. - The recurrence gates already ran for the GROUPED families (failure_recovery, rediscovery, denials, preambles). Corrections are ungated — any single flagged message reaches you — so judge them hardest; a one-off correction is only a rule if its content is plainly durable. Then apply the meaning test to everything: *would the user bet on this line?* When unsure, decline — a mined draft earns trust by being small. And spot-check recall: the pre-filter misses bare factual corrections without marker words (`meta.known_gaps`); skim one or two condensed sessions' user texts if the yield looks thin. ### B3 — validate mined rules through the backtest (receipts) For each accepted rule that is mechanically checkable, write a standard `WORK/rulebook.json` entry (schema at the top of `backtest.py`; set `"source": {"file": "mined-from-history", "line": 0}`, classify its enforcement, give it an `echo_regex`), then: python3 SCRIPTS/backtest.py --work WORK python3 SCRIPTS/compile.py --work WORK The backtest counts are the rule's receipts — a mined rule whose matcher finds nothing in the very history that suggested it is a mining false positive: drop it. Sample-verify fires exactly as in Stage 4d. compile writes review-then-arm hook proposals for hook-class mined rules — born mechanized: the best CLAUDE.md line is the one a guard enforces. ### B4 — write the chart and generate the draft Write `WORK/chart.json` (schema at the top of `generate.py`): accepted facts and rules with `occurrences`/`sessions` from mining or backtest, per-item `evidence` excerpts, `startup_tax` (copy `est_tokens`/`sessions` from candidates.json — the estimate is attributed to the recurring discovery commands' own records and results, so quote it as exactly that, an estimate), and your `declined` list. Then: python3 SCRIPTS/generate.py --work WORK This assembles `PROPOSED-CLAUDE.md` next to the report — receipts ride in HTML comments, which Claude Code strips at load, so they cost the adopter nothing. It exits nonzero if the draft breaks the official 200-line target: the doctor does not prescribe the disease it diagnoses. **Never copy the draft into the repo yourself** — adoption is the user's move. ### B5 — diagnosis, report, card Proceed to Stages 5 and 6 as usual. Mode B specifics: - `chief_complaint`: the absence plus its cost, with evidence ("No memory file exists; N sessions show M recurring rules dictated by hand"). - **Grade the gap, not the void**: D when the history shows recurring unwritten rules being re-dictated or violated; C when the mined chart is thin. F stays reserved for actively misleading files — absence is not deception. - `rule_verdicts` for backtested mined rules use verdict `undocumented` (they cannot be "ignored" — there was no file to ignore). - The report renders the Initial chart section from `chart.json` and the card switches to intake stats automatically. Tell the user where `PROPOSED-CLAUDE.md` lives, that every line carries its receipt, and that hook proposals (if any) are review-then-arm. ## Conduct - Everything runs locally; never send file contents anywhere. - Quote at most ~2 lines from any file in evidence. - In a headless or background run, do not try to "open" the report — state its path (report.html lands in the work directory's PARENT, next to report.json) and summarize it.
Comments (0)
Sign in to join the conversation.
Reviews (0)
No reviews yet.
No comments yet.