diff --git a/README.md b/README.md index 12d145d..3338f3e 100644 --- a/README.md +++ b/README.md @@ -82,8 +82,11 @@ You can also run the deterministic scripts directly (no AI needed for these): ```bash S=~/.claude/skills/codemap -# what changed since last audit +# what changed since last audit — incl. a `git` block listing the commits since the +# last codemap run (meta.rev) and which modules they touched python3 $S/scripts/scan.py --root . --state .claude/codemap/modules.json +# after an update, cache the current HEAD as the new baseline for next time +python3 $S/scripts/scan.py --root . --state .claude/codemap/modules.json --stamp-rev # find modules to act on without reading the whole state (token-cheap, for agents) python3 $S/scripts/query.py --state .claude/codemap/modules.json --max-grade C --format ids python3 $S/scripts/query.py --state .claude/codemap/modules.json --tag dual-format diff --git a/SKILL.md b/SKILL.md index 3081d3f..c13ce75 100644 --- a/SKILL.md +++ b/SKILL.md @@ -130,8 +130,9 @@ Use when no `modules.json` exists yet. concurrency; chunk if needed). 4. **Synthesize `reportThemes`** (4–7 cross-cutting patterns) from the collected findings and write them into `modules.json`. -5. **Render:** run the render command. Report the result: avg score, grade spread, worst - offenders, and the two artifact paths. +5. **Render:** run the render command. Then **stamp the git baseline** so future updates + can diff from here: `python3 scripts/scan.py --root --state --stamp-rev`. + Report the result: avg score, grade spread, worst offenders, and the two artifact paths. ## Command: `check` (is the map current? — read-only) @@ -140,24 +141,34 @@ Use when the user asks "is the architecture map up to date / still accurate?". 1. `python3 scripts/scan.py --root --state ` (no `--write`). 2. Read the JSON: report `up_to_date`, the **stale** list (code changed since audit), **unaudited** (new modules with no score), and **empty** (paths match nothing → - likely deleted modules). Do **not** modify anything. Tell the user exactly which - modules drifted and offer to run `update`. + likely deleted modules). The `git` block shows the **commits since the last codemap + run** (`meta.rev`) and which modules they touched — surface those commits so the user + sees recent history at a glance. Do **not** modify anything; offer to run `update`. 3. Also sanity-check for *new* capabilities not yet in `modules.json` (a quick look at new top-level dirs / large new files). New modules are model-discovered, not scan-detected. -## Command: `update` (incremental refresh) +## Command: `update` (incremental refresh, git-aware) -Use after code changes, or when `check` found drift. Only re-audits what changed. +Use after code changes, or when `check` found drift. Re-audits only what changed, and +uses git to show recent history and scope the work. 1. **Reconcile structure first** (cheap): if modules were added/removed/renamed, edit `modules.json` (add new module entries with `paths`; drop `empty` ones; fix globs). -2. `python3 scripts/scan.py --root --state --write` → get `needs_audit` - (= stale + unaudited). -3. **Re-audit only those modules**, each with its own independent subagent (same - protocol as `generate` step 3). Apply each via `apply_audit.py`. Fresh modules keep - their cached audit untouched — that is the whole point of the content hash. +2. **Scan + git diff:** `python3 scripts/scan.py --root --state --write`. + Read the report's **`git`** block: `commits` (since `meta.rev`, the last run) and + `changed_modules` (modules those commits touched). Show the user the recent commits — + this is the fast "what changed" view. The audit set is `needs_audit` (= stale + + unaudited); content-hash staleness already includes everything `changed_modules` lists + (plus any uncommitted edits), so re-audit `needs_audit`. If `git` is null the project + isn't a git repo — fall back to content-hash staleness only. +3. **Re-audit only those modules**, each with its own independent subagent (same protocol + as `generate` step 3). Apply each via `apply_audit.py --id --rev `. Fresh + modules keep their cached audit — that is the whole point of the content hash. 4. **Refresh `reportThemes`** if the changes are material (otherwise keep them). -5. **Render.** Summarize what changed: which modules were re-scored and how their score moved. +5. **Render**, then **stamp the baseline**: + `python3 scripts/scan.py --root --state --stamp-rev` caches the current + HEAD into `meta.rev`, so the next `update`/`check` diffs from here. Summarize which + modules were re-scored and how their score moved, with the commits that caused it. ## Command: `test ` (generate tests) diff --git a/reference/DATA_MODEL.md b/reference/DATA_MODEL.md index 4dbc121..5f11c85 100644 --- a/reference/DATA_MODEL.md +++ b/reference/DATA_MODEL.md @@ -13,7 +13,8 @@ subagents edit it) and re-render. // should be authored in this language. ids/labels stay. "subtitle": "short tagline", // optional header sub-line (write in meta.lang) "generatedAt": "2026-01-01", - "rev": "abc1234", // git rev these artifacts reflect (optional) + "rev": "abc1234", // git HEAD at the last codemap run (the baseline + // `update`/`check` diff from; set by scan.py --stamp-rev) "htmlPath": "docs/architecture-map.html", // for reciprocal links "mdPath": "docs/architecture-audit.md", "spineDesc": "A user edits … → … → persistence.", // shown on the spine view diff --git a/scripts/query.py b/scripts/query.py index f5f7c49..ee70fa9 100644 --- a/scripts/query.py +++ b/scripts/query.py @@ -28,6 +28,10 @@ GRADE_BOUND = {"A": 101, "B": 90, "C": 75, "D": 60, "F": 40} def main(): + try: + sys.stdout.reconfigure(encoding="utf-8") # findings/descriptions may be non-ASCII + except (AttributeError, ValueError): + pass ap = argparse.ArgumentParser(description="filter modules.json for agents") ap.add_argument("--state", required=True) ap.add_argument("--max-grade", choices=list(GRADE_BOUND), diff --git a/scripts/scan.py b/scripts/scan.py index 8b11866..d403eef 100644 --- a/scripts/scan.py +++ b/scripts/scan.py @@ -15,10 +15,40 @@ declares `paths` (a list of globs, relative to the project root). This script: empty — paths match no files (likely deleted / moved) * with --write, writes the fresh `loc` and `contentHash` back into the state. +It also reports, when in a git repo, what changed since the last codemap run +(`meta.rev`): the commits and which modules they touch — so `update` can show recent +history at a glance and re-audit exactly the affected modules. `--stamp-rev` caches the +current HEAD into `meta.rev` (run at the end of a successful update/generate). + Output (stdout): a JSON report the orchestrator uses to decide what to re-audit. Stdlib only. """ -import argparse, glob, hashlib, json, os, sys +import argparse, glob, hashlib, json, os, subprocess, sys + + +def git(root, *args): + try: + r = subprocess.run(["git", "-C", root, *args], + capture_output=True, text=True, timeout=15) + return r.stdout.strip() if r.returncode == 0 else None + except (OSError, subprocess.SubprocessError): + return None + + +def git_changes_since(root, since): + """commits + changed files between `since` and HEAD, or None if unavailable.""" + head = git(root, "rev-parse", "HEAD") + if not head: + return None # not a git repo + info = {"head": head, "since": since} + if not since or git(root, "rev-parse", "--verify", "--quiet", since + "^{commit}") is None: + info["commits"], info["files"] = None, None # no/unknown baseline → diff everything + return info + diff = git(root, "diff", "--name-only", since + "..HEAD") or "" + log = git(root, "log", "--pretty=format:%h %s", since + "..HEAD") or "" + info["files"] = [f for f in diff.splitlines() if f.strip()] + info["commits"] = [c for c in log.splitlines() if c.strip()] + return info DEFAULT_EXCLUDES = [ # vcs / editor @@ -74,11 +104,17 @@ def module_stats(root, module, excludes): def main(): + try: + sys.stdout.reconfigure(encoding="utf-8") # commit messages may be non-ASCII + except (AttributeError, ValueError): + pass ap = argparse.ArgumentParser() ap.add_argument("--root", default=".", help="project root") ap.add_argument("--state", required=True, help="path to modules.json") ap.add_argument("--write", action="store_true", help="write fresh loc + contentHash back into the state") + ap.add_argument("--stamp-rev", action="store_true", + help="cache current git HEAD into meta.rev (run at end of a successful update)") args = ap.parse_args() state = json.load(open(args.state, encoding="utf-8")) @@ -87,6 +123,7 @@ def main(): buckets = {"fresh": [], "stale": [], "unaudited": [], "empty": []} union_files = {} + file_index = {} # repo-relative path -> [module ids] (for git-change → module mapping) for m in state.get("modules", []): loc, chash, nfiles = module_stats(root, m, excludes) m["loc"] = loc @@ -95,6 +132,7 @@ def main(): for p, rp in iter_files(root, (m.get("paths") or []), list(excludes) + list(m.get("exclude", []))): union_files[rp] = p + file_index.setdefault(rp, []).append(m["id"]) if nfiles == 0: buckets["empty"].append(m["id"]) elif not m.get("auditedHash") or m.get("score") is None: @@ -112,7 +150,28 @@ def main(): except OSError: pass - if args.write: + # git: what changed since the last codemap run (meta.rev)? + since = state.get("meta", {}).get("rev") + gc = git_changes_since(root, since) + git_report = None + changed_modules = [] + if gc is not None: + if gc.get("files") is None: + git_report = {"head": gc["head"], "since": since, + "note": "no/unknown baseline rev — treat all unaudited/stale as the change set"} + else: + for f in gc["files"]: + for mid in file_index.get(f, []): + if mid not in changed_modules: + changed_modules.append(mid) + git_report = {"head": gc["head"], "since": since, + "commits": gc["commits"], "commit_count": len(gc["commits"]), + "changed_files": len(gc["files"]), + "changed_modules": sorted(changed_modules)} + + if args.stamp_rev and gc and gc.get("head"): + state.setdefault("meta", {})["rev"] = gc["head"] + if args.write or args.stamp_rev: meta = state.setdefault("meta", {}) meta["tracked_loc"] = tracked_loc meta["tracked_files"] = len(union_files) @@ -127,6 +186,7 @@ def main(): "needs_audit": needs, "needs_audit_count": len(needs), "up_to_date": len(needs) == 0 and not buckets["empty"], + "git": git_report, **buckets, } print(json.dumps(report, ensure_ascii=False, indent=1))