workspace: the root CLAUDE.md becomes a symlink; check 5 learns two shapes (R-230(b))
gates / gates (push) Successful in 7s
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.
This commit is contained in:
@@ -57,19 +57,91 @@ def read(path):
|
|||||||
return fh.read()
|
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")
|
target = os.path.join(workspace, "CLAUDE.md")
|
||||||
if not os.path.exists(CLAUDE_SRC):
|
if not os.path.exists(CLAUDE_SRC):
|
||||||
problems.append("versioned source missing: %s" % CLAUDE_SRC)
|
problems.append("versioned source missing: %s" % CLAUDE_SRC)
|
||||||
return
|
return
|
||||||
src_text = read(CLAUDE_SRC)
|
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 os.path.exists(target):
|
||||||
if read(target) == src_text:
|
if read(target) == src_text:
|
||||||
unchanged.append("CLAUDE.md (already byte-identical to the versioned copy)")
|
unchanged.append("CLAUDE.md (already byte-identical to the versioned copy)")
|
||||||
return
|
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:
|
if dry_run:
|
||||||
changed.append("CLAUDE.md WOULD back up and overwrite (live file differs from source)")
|
changed.append("CLAUDE.md WOULD back up and overwrite (live file differs from source)")
|
||||||
return
|
return
|
||||||
@@ -79,7 +151,8 @@ def install_claude_md(workspace, dry_run):
|
|||||||
changed.append(
|
changed.append(
|
||||||
"CLAUDE.md DIVERGED -> backed up to %s, installed from %s.\n"
|
"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 "
|
" 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
|
return
|
||||||
|
|
||||||
@@ -157,18 +230,22 @@ def main(argv):
|
|||||||
help="workspace root (default: the parent of this repo)")
|
help="workspace root (default: the parent of this repo)")
|
||||||
ap.add_argument("--settings",
|
ap.add_argument("--settings",
|
||||||
default=os.path.join(os.path.expanduser("~"), ".claude", "settings.json"))
|
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:])
|
args = ap.parse_args(argv[1:])
|
||||||
|
|
||||||
print("install_workspace%s" % (" [--dry-run]" if args.dry_run else ""))
|
print("install_workspace%s" % (" [--dry-run]" if args.dry_run else ""))
|
||||||
print(" workspace : %s" % args.workspace)
|
print(" workspace : %s" % args.workspace)
|
||||||
print(" settings : %s" % args.settings)
|
print(" settings : %s" % args.settings)
|
||||||
|
print(" mode : %s" % ("copy" if args.copy else "symlink"))
|
||||||
print("")
|
print("")
|
||||||
|
|
||||||
if not os.path.isdir(args.workspace):
|
if not os.path.isdir(args.workspace):
|
||||||
print("FAIL: workspace root does not exist: %s" % args.workspace)
|
print("FAIL: workspace root does not exist: %s" % args.workspace)
|
||||||
return 2
|
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)
|
install_hook(args.settings, args.dry_run)
|
||||||
|
|
||||||
for c in changed:
|
for c in changed:
|
||||||
|
|||||||
@@ -219,19 +219,58 @@ def check_temporary(path, failures, tally):
|
|||||||
|
|
||||||
|
|
||||||
def check_workspace_copy(workspace_root, 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")
|
live = os.path.join(workspace_root, "CLAUDE.md")
|
||||||
copy = os.path.join(workspace_root, WORKSPACE_COPY)
|
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):
|
if not os.path.exists(live) or not os.path.exists(copy):
|
||||||
tally.append(" workspace copy : n/a (not this workspace)")
|
tally.append(" workspace copy : n/a (not this workspace)")
|
||||||
return
|
return
|
||||||
with io_open(live) as a, io_open(copy) as b:
|
with io_open(live) as a, io_open(copy) as b:
|
||||||
same = a.read() == b.read()
|
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:
|
if not same:
|
||||||
failures.append(
|
failures.append(
|
||||||
"%s and %s have diverged. The live workspace file sits in a directory that is not a "
|
"%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 "
|
"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)
|
% (live, copy)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -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("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 <tmp>/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():
|
def main():
|
||||||
print("test_instructions_gate")
|
print("test_instructions_gate")
|
||||||
for fn in sorted(
|
for fn in sorted(
|
||||||
|
|||||||
Reference in New Issue
Block a user