f49b1f390b
gates / gates (push) Successful in 7s
Two files kept identical by hand and one check is a divergence class policed forever; one file reachable by two paths cannot diverge at all. install_workspace.py now links by default, MIGRATES an existing regular file (backing it up first and SAYING SO if it differed -- that difference is the last chance to notice an unsynced edit), and keeps --copy for a clone that wants the old shape. Check 5 asserts a different thing per shape: for a link, that it points at the versioned copy and resolves to a real file; for two files, byte-identity as before. A dangling link is worse than a diverged copy -- the instructions load NOTHING and there is no content left to notice is wrong -- so that case is red-proofed. NOT yet proven to LOAD: that needs a fresh session and a hook line, which is Phase 7. If it does not load, this reverts to the copy.
271 lines
11 KiB
Python
271 lines
11 KiB
Python
# -*- 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, mode):
|
|
"""Install the workspace-root CLAUDE.md, as a symlink by default.
|
|
|
|
THE LINK IS THE POINT (R-230(b)). Two files kept identical by hand and one check is a
|
|
divergence class that has to be policed forever; one file reachable by two paths cannot diverge
|
|
at all. Claude Code reads through symlinks — the four skills have been symlinks into this tree
|
|
for months — but the link is still PROVEN from a fresh session rather than assumed, because a
|
|
workspace file that silently stops loading is worse than two files kept in step by hand.
|
|
|
|
`--copy` keeps the old two-file shape for a clone where linking is not wanted; check 5 accepts
|
|
either.
|
|
"""
|
|
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)
|
|
rel = os.path.relpath(CLAUDE_SRC, workspace)
|
|
|
|
if mode == "link":
|
|
if os.path.islink(target):
|
|
if os.path.realpath(target) == os.path.realpath(CLAUDE_SRC):
|
|
if not os.path.isfile(os.path.realpath(target)):
|
|
problems.append(
|
|
"%s is a symlink to %s but the target does not resolve — the workspace "
|
|
"instructions load NOTHING." % (target, os.readlink(target))
|
|
)
|
|
else:
|
|
unchanged.append("CLAUDE.md (already a symlink -> %s)" % rel)
|
|
return
|
|
if dry_run:
|
|
changed.append("CLAUDE.md WOULD re-point the symlink to %s" % rel)
|
|
return
|
|
os.unlink(target)
|
|
os.symlink(rel, target)
|
|
changed.append("CLAUDE.md symlink re-pointed to %s" % rel)
|
|
return
|
|
|
|
if os.path.exists(target):
|
|
# MIGRATION: a real file is here. Back it up before it stops being a file, and say so
|
|
# if it differed — that difference is the last chance to notice an unsynced edit.
|
|
differed = read(target) != src_text
|
|
if dry_run:
|
|
changed.append(
|
|
"CLAUDE.md WOULD back up the regular file and replace it with a symlink%s"
|
|
% (" (IT DIFFERS from the versioned copy)" if differed else "")
|
|
)
|
|
return
|
|
b = backup(target)
|
|
os.unlink(target)
|
|
os.symlink(rel, target)
|
|
changed.append(
|
|
"CLAUDE.md MIGRATED file -> symlink (%s backed up to %s)%s"
|
|
% (
|
|
"content differed" if differed else "content was identical",
|
|
os.path.basename(b),
|
|
"\n THE LIVE FILE DIFFERED — check the backup before discarding it."
|
|
if differed
|
|
else "",
|
|
)
|
|
)
|
|
return
|
|
|
|
if dry_run:
|
|
changed.append("CLAUDE.md WOULD create symlink -> %s" % rel)
|
|
return
|
|
os.symlink(rel, target)
|
|
changed.append("CLAUDE.md symlink created -> %s" % rel)
|
|
return
|
|
|
|
# --- copy mode: the original two-file shape ---
|
|
if os.path.islink(target):
|
|
if dry_run:
|
|
changed.append("CLAUDE.md WOULD replace the symlink with a real copy")
|
|
return
|
|
os.unlink(target)
|
|
with open(target, "w", encoding="utf-8") as fh:
|
|
fh.write(src_text)
|
|
changed.append("CLAUDE.md symlink replaced with a real copy")
|
|
return
|
|
|
|
if os.path.exists(target):
|
|
if read(target) == src_text:
|
|
unchanged.append("CLAUDE.md (already byte-identical to the versioned copy)")
|
|
return
|
|
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"))
|
|
ap.add_argument("--copy", action="store_true",
|
|
help="install the workspace CLAUDE.md as a real copy instead of a symlink "
|
|
"(the pre-2026-08-06 shape; check 5 accepts either)")
|
|
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(" mode : %s" % ("copy" if args.copy else "symlink"))
|
|
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, "copy" if args.copy else "link")
|
|
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))
|