diff --git a/scripts/CHANGELOG.md b/scripts/CHANGELOG.md index e75c267..8d54ce5 100644 --- a/scripts/CHANGELOG.md +++ b/scripts/CHANGELOG.md @@ -1,3 +1,35 @@ +## install_workspace.py + rules_report.py — the workspace survives the machine (2026-08-06, R-229) + +The workspace-root `CLAUDE.md` and the `InstructionsLoaded` hook shape every session and existed on +**one host** — the one that cannot be rebuilt from anything else. `install_workspace.py` lays both +down from versioned copies. + +**Canonical-path decision:** `documentation/runbooks/workspace-CLAUDE.md` stays the source. +`workspace/` carries only the hook fragment. A `workspace/CLAUDE.md` would be a *third* copy of a +file whose entire problem is that copies drift, and check 5 already enforces byte-identity against +the runbooks path. + +**Where this deliberately differs from `install_skills.py`:** that script's targets are disposable — +a skill dir can be deleted and re-linked losing nothing. These are **live configuration**. +`~/.claude/settings.json` holds permissions, plugins and effort level this repo knows nothing about. +So every write is preceded by a timestamped backup; `settings.json` is **merged**, this script owning +exactly one key (`hooks.InstructionsLoaded`); a diverged `CLAUDE.md` is backed up and *reported*, not +silently resolved; and an unparseable `settings.json` is **refused**, never overwritten — a malformed +settings file disables every setting in it, and overwriting would destroy whatever was mid-fix. + +Proven: merge preserved all 7 top-level keys byte-identically (`sha256` of the file minus `.hooks` +unchanged across the write), and a second run wrote nothing — *idempotent* means "changed nothing the +second time", not "ran twice without erroring". + +**`rules_report.py`** answers what the hook log exists for: which rules fired, how often, and **which +never have**. The empty column is the point — a never-fired rule is mis-globbed or dead, which is the +built-but-never-wired class applied to instructions. It prints each silent rule's `paths:` beside it +so "wrong glob" is distinguishable from "quiet month", and it judges neither. First run: 6 of 9 rule +files had never fired. **Caveat: the log only covers since the hook was armed** — `gates.md` shows +silent despite having fired earlier the same day, before installation. + +The hook now **self-rotates at 5 MB**, one generation, since the log is otherwise unbounded. + ## instructions_gate.py — check 6: the auto-memory index (2026-08-06, R-229) **The bigger half of what loads was watched by nothing.** Measured at the workspace root, the diff --git a/scripts/install_workspace.py b/scripts/install_workspace.py new file mode 100644 index 0000000..def80e3 --- /dev/null +++ b/scripts/install_workspace.py @@ -0,0 +1,193 @@ +# -*- coding: utf-8 -*- +"""Lay down the workspace files that live outside every repo, from their versioned copies. + +Usage: python3 scripts/install_workspace.py [--dry-run] [--workspace DIR] [--settings FILE] + +WHAT THIS EXISTS FOR. Claude Code runs from /mnt/5_hdd/felhom.eu/git, which is not a git repository. +The workspace-root CLAUDE.md and the InstructionsLoaded hook shape every session and existed on ONE +machine — the one host that cannot be rebuilt from anything else. This restores them. + +WHAT IT DOES NOT TOUCH. The auto-memory store. That is backed up, not versioned — see +workspace/README.md for why, and /opt/backup/scripts/backup-config.sh for where. + +THE ONE WAY THIS DIFFERS FROM install_skills.py, AND IT IS THE IMPORTANT ONE. That script's targets +are disposable: a skill directory can be deleted and re-linked with nothing lost. These targets are +LIVE CONFIGURATION. ~/.claude/settings.json holds permissions, plugins and effort level that this +repo knows nothing about, and the workspace CLAUDE.md may have been edited on the machine since the +last sync. So: + + - every write is preceded by a TIMESTAMPED BACKUP of the file being replaced; + - settings.json is MERGED, never rewritten from a template — this script owns exactly one key, + hooks.InstructionsLoaded, and every other key is carried through untouched; + - a diverging CLAUDE.md is reported, not silently resolved in either direction. + +IDEMPOTENT. A second run writes nothing and says so. That is asserted by the caller in the session +report, because "it ran twice without error" and "it changed nothing the second time" are different +claims and only the second one means idempotent. +""" +import argparse +import datetime +import json +import os +import shutil +import sys + +REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +# Canonical source of the workspace instructions. Deliberately NOT workspace/ — see workspace/README.md. +CLAUDE_SRC = os.path.join(REPO, "documentation", "runbooks", "workspace-CLAUDE.md") +HOOK_SRC = os.path.join(REPO, "workspace", "hooks", "instructions-loaded.json") + +changed = [] +unchanged = [] +problems = [] + + +def stamp(): + return datetime.datetime.now().strftime("%Y%m%d-%H%M%S") + + +def backup(path): + dst = "%s.bak-%s" % (path, stamp()) + shutil.copy2(path, dst) + return dst + + +def read(path): + with open(path, "r", encoding="utf-8") as fh: + return fh.read() + + +def install_claude_md(workspace, dry_run): + target = os.path.join(workspace, "CLAUDE.md") + if not os.path.exists(CLAUDE_SRC): + problems.append("versioned source missing: %s" % CLAUDE_SRC) + return + src_text = read(CLAUDE_SRC) + + if os.path.exists(target): + if read(target) == src_text: + unchanged.append("CLAUDE.md (already byte-identical to the versioned copy)") + return + # Diverged. Back up FIRST, install, and report the direction so a human can judge which + # side was right — this script must not silently pick one. + if dry_run: + changed.append("CLAUDE.md WOULD back up and overwrite (live file differs from source)") + return + b = backup(target) + with open(target, "w", encoding="utf-8") as fh: + fh.write(src_text) + changed.append( + "CLAUDE.md DIVERGED -> backed up to %s, installed from %s.\n" + " If the LIVE file was the newer one, restore it and re-sync the versioned " + "copy instead; instructions_gate check 5 enforces they match." % (os.path.basename(b), CLAUDE_SRC) + ) + return + + if dry_run: + changed.append("CLAUDE.md WOULD create (absent)") + return + with open(target, "w", encoding="utf-8") as fh: + fh.write(src_text) + changed.append("CLAUDE.md created from %s" % CLAUDE_SRC) + + +def install_hook(settings_path, dry_run): + if not os.path.exists(HOOK_SRC): + problems.append("versioned source missing: %s" % HOOK_SRC) + return + fragment = json.loads(read(HOOK_SRC)) + want = fragment.get("hooks", {}).get("InstructionsLoaded") + if not want: + problems.append("%s declares no hooks.InstructionsLoaded" % HOOK_SRC) + return + + if os.path.exists(settings_path): + try: + settings = json.loads(read(settings_path)) + except ValueError as e: + # A broken settings.json silently disables EVERY setting in it. Never overwrite one we + # could not parse — that would destroy whatever the user was mid-way through fixing. + problems.append( + "%s is not valid JSON (%s). Refusing to write: a malformed settings file disables " + "every setting in it, and overwriting would destroy the original." % (settings_path, e) + ) + return + else: + settings = {} + + have = settings.get("hooks", {}).get("InstructionsLoaded") + if have == want: + unchanged.append("settings.json (hooks.InstructionsLoaded already current)") + return + + if dry_run: + changed.append("settings.json WOULD merge hooks.InstructionsLoaded (%s)" + % ("replacing an existing one" if have else "adding")) + return + + before_keys = sorted(settings.keys()) + if os.path.exists(settings_path): + b = backup(settings_path) + else: + b = None + os.makedirs(os.path.dirname(settings_path), exist_ok=True) + + settings.setdefault("hooks", {})["InstructionsLoaded"] = want + with open(settings_path, "w", encoding="utf-8") as fh: + json.dump(settings, fh, indent=2, ensure_ascii=False) + fh.write("\n") + + after_keys = sorted(json.loads(read(settings_path)).keys()) + lost = set(before_keys) - set(after_keys) + if lost: + problems.append("MERGE LOST KEYS from settings.json: %s" % ", ".join(sorted(lost))) + changed.append( + "settings.json %s hooks.InstructionsLoaded%s; %d top-level key(s) carried through unchanged" + % ("replaced" if have else "added", + (" (backup %s)" % os.path.basename(b)) if b else "", + len(before_keys)) + ) + + +def main(argv): + ap = argparse.ArgumentParser() + ap.add_argument("--dry-run", action="store_true", + help="report what would change; write nothing") + ap.add_argument("--workspace", default=os.path.dirname(REPO), + help="workspace root (default: the parent of this repo)") + ap.add_argument("--settings", + default=os.path.join(os.path.expanduser("~"), ".claude", "settings.json")) + args = ap.parse_args(argv[1:]) + + print("install_workspace%s" % (" [--dry-run]" if args.dry_run else "")) + print(" workspace : %s" % args.workspace) + print(" settings : %s" % args.settings) + print("") + + if not os.path.isdir(args.workspace): + print("FAIL: workspace root does not exist: %s" % args.workspace) + return 2 + + install_claude_md(args.workspace, args.dry_run) + install_hook(args.settings, args.dry_run) + + for c in changed: + print("CHANGED %s" % c) + for u in unchanged: + print("unchanged %s" % u) + for p in problems: + print("PROBLEM %s" % p) + + print("") + if problems: + print("install_workspace: %d PROBLEM(S)" % len(problems)) + return 1 + if not changed: + print("install_workspace: nothing to do — already installed (idempotent)") + else: + print("install_workspace: %d change(s)" % len(changed)) + return 0 + + +if __name__ == "__main__": + sys.exit(main(sys.argv)) diff --git a/scripts/rules_report.py b/scripts/rules_report.py new file mode 100644 index 0000000..4aec3a6 --- /dev/null +++ b/scripts/rules_report.py @@ -0,0 +1,134 @@ +# -*- coding: utf-8 -*- +"""Which instruction files actually load, how often — and which NEVER have. + +Usage: python3 scripts/rules_report.py [--log FILE] [--workspace DIR] [--days N] + +READS the InstructionsLoaded hook log (~/.claude/instructions-loaded.jsonl, plus its rotated .1) and +CROSS-REFERENCES it against every .claude/rules/*.md across the workspace. + +THE COLUMN THAT MATTERS IS THE EMPTY ONE. A rule that has never fired is not a rule — it is either +mis-globbed (its `paths:` never match the files people actually edit) or dead (the surface it guards +is gone). That is the built-but-never-wired failure class, which this project has shipped four times, +applied to instructions: the rule file exists, reads correctly, passes the gate that checks it +declares `paths:` — and reaches the model never. + +A never-fired rule is NOT automatically a defect. A rule scoped to a surface nobody has touched since +it was written is simply untested. The report says which, it does not judge: `paths:` globs are +printed beside each silent rule so the reader can tell "wrong glob" from "quiet month". + +Exit code is always 0 — this is a report, not a gate. +""" +import argparse +import collections +import json +import os +import re +import sys + + +def load_events(paths, days): + import datetime + cutoff = None + if days: + cutoff = (datetime.datetime.utcnow() - datetime.timedelta(days=days)).strftime("%Y-%m-%dT%H:%M:%SZ") + events, bad = [], 0 + for p in paths: + if not os.path.exists(p): + continue + with open(p, "r", encoding="utf-8", errors="replace") as fh: + for line in fh: + line = line.strip() + if not line: + continue + try: + e = json.loads(line) + except ValueError: + bad += 1 + continue + if cutoff and (e.get("ts") or "") < cutoff: + continue + events.append(e) + return events, bad + + +def find_rule_files(workspace): + """Every .claude/rules/*.md in the workspace, with its declared paths: globs.""" + out = {} + for repo in sorted(os.listdir(workspace)): + rd = os.path.join(workspace, repo, ".claude", "rules") + if not os.path.isdir(rd): + continue + for name in sorted(os.listdir(rd)): + if not name.endswith(".md"): + continue + full = os.path.join(rd, name) + try: + with open(full, "r", encoding="utf-8") as fh: + head = fh.read(2048) + except OSError: + head = "" + m = re.search(r"^paths:\s*(.+)$", head, re.M) + globs = m.group(1).strip() if m else ("(unconditional)" if re.search( + r"^unconditional:\s*true", head, re.M) else "(NO paths: — loads every session)") + out[full] = (repo, name, globs) + return out + + +def main(argv): + home = os.path.expanduser("~") + ap = argparse.ArgumentParser() + ap.add_argument("--log", default=os.path.join(home, ".claude", "instructions-loaded.jsonl")) + ap.add_argument("--workspace", + default=os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))) + ap.add_argument("--days", type=int, default=0, help="only count events newer than N days") + args = ap.parse_args(argv[1:]) + + logs = [args.log, args.log + ".1"] + events, bad = load_events(logs, args.days) + + print("rules_report") + print(" log : %s%s" % (args.log, " (+ rotated .1)" if os.path.exists(args.log + ".1") else "")) + print(" workspace : %s" % args.workspace) + print(" events : %d%s%s" % (len(events), + (" (last %d days)" % args.days) if args.days else "", + (" [%d unparseable lines skipped]" % bad) if bad else "")) + if not events: + print("\n No events. The hook may not be installed — see scripts/install_workspace.py.") + + by_reason = collections.Counter(e.get("load_reason") or "?" for e in events) + print("\n== why instructions loaded ==") + for reason, n in by_reason.most_common(): + print(" %-18s %d" % (reason, n)) + + fired = collections.Counter() + triggers = collections.defaultdict(collections.Counter) + for e in events: + fp = e.get("file_path") or "?" + fired[fp] += 1 + t = e.get("trigger_file_path") + if t: + triggers[fp][os.path.basename(t)] += 1 + + print("\n== instruction files that LOADED ==") + if not fired: + print(" (none)") + for fp, n in fired.most_common(): + short = fp.replace(args.workspace + os.sep, "") + ex = triggers[fp].most_common(1) + print(" %5d %-58s %s" % (n, short, ("e.g. via %s" % ex[0][0]) if ex else "")) + + rules = find_rule_files(args.workspace) + silent = [(p, v) for p, v in sorted(rules.items()) if p not in fired] + print("\n== rule files that have NEVER fired (%d of %d) ==" % (len(silent), len(rules))) + if not silent: + print(" none — every rule file in the workspace has loaded at least once") + for p, (repo, name, globs) in silent: + print(" %-24s %-18s paths: %s" % (repo, name, globs)) + if silent: + print("\n A never-fired rule is mis-globbed, dead, or simply guarding a surface nobody has") + print(" edited since it was written. The globs above are printed so you can tell which.") + return 0 + + +if __name__ == "__main__": + sys.exit(main(sys.argv)) diff --git a/workspace/README.md b/workspace/README.md new file mode 100644 index 0000000..30df7cc --- /dev/null +++ b/workspace/README.md @@ -0,0 +1,57 @@ +# `workspace/` — the parts of the workspace that live outside every repo + +Claude Code runs from `/mnt/5_hdd/felhom.eu/git`, which is **not a git repository**. Three things +that shape every session live there or in `~/.claude/` and are therefore on one machine only: + +| Thing | Live path | Versioned where | Installed by | +|---|---|---|---| +| workspace-root instructions | `/CLAUDE.md` | `documentation/runbooks/workspace-CLAUDE.md` | `scripts/install_workspace.py` | +| `InstructionsLoaded` hook | `~/.claude/settings.json` | `workspace/hooks/instructions-loaded.json` | `scripts/install_workspace.py` | +| auto-memory store | `/.claude-memory/` | **not versioned — backed up** | `dooplex-backup.service` | + +## Why the CLAUDE.md source is NOT in this directory + +`documentation/runbooks/workspace-CLAUDE.md` is **canonical** and stays where it is. It already has a +gate enforcing byte-identity with the live file (`instructions_gate.py` check 5), and it is the path +every existing pointer names. Copying it here would create a *third* copy of a file whose whole +problem is that copies drift — the installer reads the canonical path instead. + +## Why the memory store is backed up and not committed + +It is **auto-written**: Claude writes it, so nobody reviews it before it lands. It may name hosts, +paths and out-of-band secret locations that this project's secrets rule keeps out of committed +files. A scan on 2026-08-06 found no credential *values* (535 keyword mentions across 104 files, one +`key: value`-shaped hit that was prose, zero private-key blocks) — but "no secrets today" is not a +property a directory keeps on its own when a machine writes to it unattended. + +So it is protected by `dooplex-backup.service`'s User Data component instead +(`CLAUDE_MEMORY_DIR` in `/opt/backup/scripts/backup-config.sh`). + +The exact change, recorded here because the file it was made in is **not** version-controlled: + +```sh +# /opt/backup/scripts/backup-config.sh +export CLAUDE_MEMORY_DIR="/mnt/5_hdd/felhom.eu/git/.claude-memory" + +# /opt/backup/scripts/backup-data.sh — restic takes multiple paths; an absent optional path is +# skipped with a printed reason, never a failure +local backup_paths=("${DATA_SOURCE_DIR}") +if [ -n "${CLAUDE_MEMORY_DIR:-}" ] && [ -d "${CLAUDE_MEMORY_DIR}" ]; then + backup_paths+=("${CLAUDE_MEMORY_DIR}") +fi +restic -r "${RESTIC_REPO_DATA}" backup "${backup_paths[@]}" "${exclude_args[@]}" ... +``` + +**`/opt/backup/scripts/` is itself unversioned host state on DooPlex** — no repo tracks it. That is a +fresh instance of the very class this directory exists to close, found while closing it. Filed as a +register item rather than fixed here: bringing a root-owned production backup script under version +control (and deciding what installs it) is its own change, not a rider on this one. + +**One-time cost of the change, so it is not mistaken for a fault:** adding a path to a restic path +set invalidates the parent-snapshot match, so the first run after this edit logs +`no parent snapshot found, will read all files` and re-reads the whole source (405 GiB). Chunk-level +dedup means storage barely moves; subsequent runs find a parent and are incremental again. + +**Caveat, stated because it is easy to misread as safety:** that backup's destination +(`/mnt/5_hdd/backup`) is on the **same physical disk** as the store. It protects against accidental +deletion, **not** against loss of `sda1`, and there is no off-site leg for the DooPlex backup set. diff --git a/workspace/hooks/instructions-loaded.json b/workspace/hooks/instructions-loaded.json new file mode 100644 index 0000000..e186a8b --- /dev/null +++ b/workspace/hooks/instructions-loaded.json @@ -0,0 +1,24 @@ +{ + "_comment": [ + "InstructionsLoaded hook — logs which instruction file loaded, when, and WHY.", + "Installed into ~/.claude/settings.json by scripts/install_workspace.py (merged, never replaced).", + "load_reason is the field that answers 'why': session_start | nested_traversal |", + "path_glob_match | include | compact. trigger_file_path names the file whose read caused it.", + "The hook is observability-only; Claude Code does not let it block.", + "Self-rotating at 5 MB, one generation kept, because the log is unbounded otherwise.", + "Read it with scripts/rules_report.py — which rules fired, and which NEVER have." + ], + "hooks": { + "InstructionsLoaded": [ + { + "hooks": [ + { + "type": "command", + "command": "L=\"$HOME/.claude/instructions-loaded.jsonl\"; if [ -f \"$L\" ] && [ \"$(stat -c%s \"$L\" 2>/dev/null || echo 0)\" -gt 5242880 ]; then mv -f \"$L\" \"$L.1\"; fi; jq -c '{ts:(now|todate), file_path, memory_type, load_reason, trigger_file_path, parent_file_path, globs, session_id, cwd}' >> \"$L\" 2>/dev/null || true", + "timeout": 5 + } + ] + } + ] + } +}