workspace: version the root CLAUDE.md + InstructionsLoaded hook, and report which rules fire (R-229)
gates / gates (push) Successful in 8s
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:
@@ -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)
|
## 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
|
**The bigger half of what loads was watched by nothing.** Measured at the workspace root, the
|
||||||
|
|||||||
@@ -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))
|
||||||
@@ -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))
|
||||||
@@ -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.
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user