From f49b1f390b50d6cbb39cd2498a0c9b85088bf335 Mon Sep 17 00:00:00 2001 From: kisfenyo Date: Thu, 6 Aug 2026 11:41:36 +0200 Subject: [PATCH] workspace: the root CLAUDE.md becomes a symlink; check 5 learns two shapes (R-230(b)) 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. --- scripts/install_workspace.py | 87 +++++++++++++++++++++++++++++-- scripts/instructions_gate.py | 43 ++++++++++++++- scripts/test_instructions_gate.py | 62 ++++++++++++++++++++++ 3 files changed, 185 insertions(+), 7 deletions(-) diff --git a/scripts/install_workspace.py b/scripts/install_workspace.py index def80e3..8d4a161 100644 --- a/scripts/install_workspace.py +++ b/scripts/install_workspace.py @@ -57,19 +57,91 @@ def read(path): return fh.read() -def install_claude_md(workspace, dry_run): +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 - # 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 @@ -79,7 +151,8 @@ def install_claude_md(workspace, dry_run): 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) + "copy instead; instructions_gate check 5 enforces they match." + % (os.path.basename(b), CLAUDE_SRC) ) return @@ -157,18 +230,22 @@ def main(argv): 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) + 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: diff --git a/scripts/instructions_gate.py b/scripts/instructions_gate.py index 93f653e..59b70d2 100644 --- a/scripts/instructions_gate.py +++ b/scripts/instructions_gate.py @@ -219,19 +219,58 @@ def check_temporary(path, failures, tally): def check_workspace_copy(workspace_root, failures, tally): + """Check 5 — the live workspace CLAUDE.md agrees with its versioned copy. + + TWO LEGAL SHAPES, and the check asserts a different thing in each: + + LINK (this workspace since 2026-08-06) — the live file IS the versioned file. Divergence is + not possible, so there is nothing to compare; what can still break is the LINK, so that + is what is asserted: it points at the versioned copy and resolves to a real file. A + dangling link is worse than a diverged copy — the workspace instructions stop loading + entirely, and nothing else would say so. + COPY (any other clone) — two real files, asserted byte-identical as before. + + The link shape is not forced on anyone: a clone that has two files is checked as two files. + """ live = os.path.join(workspace_root, "CLAUDE.md") copy = os.path.join(workspace_root, WORKSPACE_COPY) + + if os.path.islink(live): + target = os.readlink(live) + resolved = os.path.realpath(live) + expected = os.path.realpath(copy) + ok_target = resolved == expected + ok_real = os.path.isfile(resolved) + tally.append( + " workspace file : SYMLINK -> %s (%s)" + % (target, "resolves to the versioned copy" if ok_target and ok_real else "BROKEN") + ) + if not ok_real: + failures.append( + "%s is a symlink to %s, which does not resolve to a real file. A dangling link " + "means the workspace instructions load NOTHING — worse than two files that " + "disagree, because there is no content to notice is wrong." % (live, target) + ) + elif not ok_target: + failures.append( + "%s is a symlink, but it resolves to %s instead of the versioned copy %s. The " + "whole point of the link is that there is exactly one file; pointing it elsewhere " + "reintroduces the divergence it removed." % (live, resolved, expected) + ) + return + if not os.path.exists(live) or not os.path.exists(copy): tally.append(" workspace copy : n/a (not this workspace)") return with io_open(live) as a, io_open(copy) as b: same = a.read() == b.read() - tally.append(" workspace copy identical : %s" % ("yes" if same else "NO")) + tally.append(" workspace copy identical : %s (two-file shape)" % ("yes" if same else "NO")) if not same: failures.append( "%s and %s have diverged. The live workspace file sits in a directory that is not a " "git repo, so the copy is its only version-controlled record — nothing but this " - "check enforces that they agree. Copy the live file over the versioned one." + "check enforces that they agree. Copy the live file over the versioned one, or make " + "the live file a symlink to it (see scripts/install_workspace.py --link)." % (live, copy) ) diff --git a/scripts/test_instructions_gate.py b/scripts/test_instructions_gate.py index 9f79354..aaa64e3 100644 --- a/scripts/test_instructions_gate.py +++ b/scripts/test_instructions_gate.py @@ -454,6 +454,68 @@ def test_memory_version_literal_and_address_warn_but_do_not_fail(): check("loopback is not reported", "127.0.0.1" not in out) +# --- check 5: the workspace file has TWO legal shapes -------------------------------------------- + + +def make_workspace_pair(tmp, live_content=None, link=False, link_target=None): + """Build /CLAUDE.md + the versioned copy, as either a copy or a symlink.""" + cp = os.path.join(tmp, "felhom.eu", "documentation", "runbooks") + os.makedirs(cp, exist_ok=True) + versioned = os.path.join(cp, "workspace-CLAUDE.md") + with open(versioned, "w", encoding="utf-8") as fh: + fh.write("# Workspace\n") + live = os.path.join(tmp, "CLAUDE.md") + if link: + os.symlink(link_target or os.path.relpath(versioned, tmp), live) + else: + with open(live, "w", encoding="utf-8") as fh: + fh.write(live_content if live_content is not None else "# Workspace\n") + return live, versioned + + +def test_workspace_symlink_shape_passes(): + with tempfile.TemporaryDirectory() as tmp: + make_workspace_pair(tmp, link=True) + root = make_repo(tmp, "# Repo\n") + rc, out = run_gate(root) + check("symlink shape exits 0", rc == 0, "rc=%d" % rc) + check("tally reports the symlink", "SYMLINK" in out) + + +def test_dangling_workspace_symlink_fails(): + """A dangling link is worse than a diverged copy: the instructions load NOTHING and there is no + content left to notice is wrong.""" + with tempfile.TemporaryDirectory() as tmp: + _, versioned = make_workspace_pair(tmp, link=True) + os.unlink(versioned) + root = make_repo(tmp, "# Repo\n") + rc, out = run_gate(root) + check("dangling workspace symlink exits non-zero", rc != 0, "rc=%d" % rc) + check("message says the instructions load nothing", "load NOTHING" in out) + + +def test_workspace_symlink_to_the_wrong_file_fails(): + with tempfile.TemporaryDirectory() as tmp: + other = os.path.join(tmp, "somewhere-else.md") + with open(other, "w", encoding="utf-8") as fh: + fh.write("# Not the versioned copy\n") + make_workspace_pair(tmp, link=True, link_target="somewhere-else.md") + root = make_repo(tmp, "# Repo\n") + rc, out = run_gate(root) + check("symlink to the wrong target exits non-zero", rc != 0, "rc=%d" % rc) + check("message says it reintroduces divergence", "reintroduces the divergence" in out) + + +def test_two_file_shape_still_checked_byte_identical(): + """A clone elsewhere may legitimately have two files; the link shape is not forced on anyone.""" + with tempfile.TemporaryDirectory() as tmp: + make_workspace_pair(tmp, live_content="# Workspace DIVERGED\n") + root = make_repo(tmp, "# Repo\n") + rc, out = run_gate(root) + check("two-file shape still catches divergence", rc != 0, "rc=%d" % rc) + check("tally names the two-file shape", "two-file shape" in out) + + def main(): print("test_instructions_gate") for fn in sorted(