Claude Skill

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

LLM Mart · 0 points · 10 views 26 listing impressions 0 install-command copies
Virus-scanned Reviewed automatically before listing.

Full trust report

Download agent-clinic-claude-md-doctor-skills_claude-md-doctor-7492065.zip · 66 KB

Install

skills CLI npx skills add https://github.com/agent-clinic/claude-md-doctor/tree/main/skills/claude-md-doctor
Claude Code claude plugin marketplace add https://llmmart.ai/marketplace.json && claude plugin install agent-clinic-claude-md-doctor@llmmart
Git 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 --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:

"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 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.
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("&", "&amp;").replace("<", "&lt;")
                  .replace(">", "&gt;").replace('"', "&quot;"))
      
      
      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: [![CLAUDE.md health](%s)]"
                "(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("<!--", "&lt;!--").replace("-->", "--&gt;")
      
      
      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.

No comments yet.

Reviews (0)

No reviews yet.

Related