workspace: version the root CLAUDE.md + InstructionsLoaded hook, and report which rules fire (R-229)
gates / gates (push) Successful in 8s

install_workspace.py lays down the two things that shaped every session while existing on one
host only. Unlike install_skills.py the targets are LIVE CONFIG, so: timestamped backup before
every write, settings.json MERGED (this script owns exactly one key), a diverged CLAUDE.md
reported rather than silently resolved, and an unparseable settings.json refused outright.

Proven: all 7 top-level settings keys survived byte-identically, and run 2 wrote nothing.

rules_report.py surfaces the column that matters -- rules that have NEVER fired, which are
mis-globbed or dead. 6 of 9 on first run. The hook now self-rotates at 5 MB.

The memory store is BACKED UP, NOT COMMITTED (auto-written, may name hosts/paths): added to
dooplex-backup.service's User Data component. /opt/backup/scripts/ is itself unversioned host
state -- filed, not fixed here.
This commit is contained in:
2026-08-06 10:57:01 +02:00
parent f27aed87cd
commit f65ea89a24
5 changed files with 440 additions and 0 deletions
+32
View File
@@ -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
+193
View File
@@ -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))
+134
View File
@@ -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))
+57
View File
@@ -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 | `<workspace>/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 | `<workspace>/.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.
+24
View File
@@ -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
}
]
}
]
}
}