Engine-only install (3) prune dangling repo-owned symlinks and dead hook entries - #466
Closed
EdbertChan wants to merge 4 commits into
Closed
Conversation
…es — Review claim: Every install run removes dangling symlinks into this repo from the harness hook and skill directories and removes ~/.claude/settings.json hook entries whose $HOME/.claude/hooks/ script is missing, leaving everything else untouched.
Review lane: policy
Safety invariant: A symlink is removed only when it is dangling (`[ -L path ] && [ ! -e path ]`) and its raw `readlink` target starts with "$REPO_DIR/". A settings.json hook entry is removed only when its command names a path under $HOME/.claude/hooks/ whose file does not exist. Real files, live symlinks, symlinks into other locations, and every other settings.json key, matcher, or command are preserved. Confirmed by the user on 2026-09-01.
Effectiveness measurement: ~/.claude/hooks goes from 3 dangling repo links to 0 and settings.json from 1 dead hook entry to 0 after one install run, measured by `find ~/.claude/hooks -maxdepth 1 -type l ! -exec test -e {} \; -print | wc -l` and by grepping settings.json for the dead command.
Slice rationale: Dead-state cleanup is one claim that applies to both install modes; it is kept apart from the engine-only flag so the reviewer can judge the deletion rules on their own.
Architectural effect: install.sh gains a post-link hygiene pass and one new engine-owned script, scripts/prune_dead_hook_entries.py; hook registration itself is unchanged.
Goal: Add the dangling-symlink prune to install.sh, add the settings.json prune script with unit tests, wire it into install.sh, and cover both in the install tests.
Motivation: Deleted worktrees leave symlinks and settings entries behind, and the dead entry prints an error on every Bash command in Claude Code.
Alternative considerations: Pruning any dangling symlink regardless of target was rejected because other tools own links in those directories; rewriting settings.json wholesale was rejected because it carries unrelated user config, so the script does a targeted removal like the existing marker-based merges.
Implementation details: In install.sh, after the hook link sections and after the engine-only skill prune, add a loop over ~/.claude/hooks, ~/.cursor/hooks, ~/.codex/hooks, ~/.claude/skills, ~/.cursor/skills, ~/.codex/skills: for each entry that is a symlink whose target does not exist and whose raw readlink output starts with "$REPO_DIR/", rm it and print "prune <name> (dangling link into this repo)". Create scripts/prune_dead_hook_entries.py (stdlib only): load ~/.claude/settings.json if present; for each event in the "hooks" object and each matcher group, drop hook entries whose "command" contains a token beginning with "$HOME/.claude/hooks/" (or the expanded home path) whose file does not exist; drop matcher groups left with an empty "hooks" array; write the file back only if something changed, with 2-space indentation; print one line per removed entry and "ok no dead hook entries" otherwise; leave commands that never mention $HOME/.claude/hooks/ alone. Expose a pure function `prune(settings, exists)` returning `(new_settings, removed)` so tests can pass a fake exists predicate. Call `python3 "$REPO_DIR/scripts/prune_dead_hook_entries.py"` from install.sh right after the existing settings.json merge section. Create tests/test_prune_dead_hook_entries.py with synthetic settings: a dead entry is removed, a live entry stays, an entry outside $HOME/.claude/hooks/ stays even if its file is missing, an emptied matcher group is dropped, unrelated top-level keys survive. In tests/test_install.py add cases: a pre-seeded dangling symlink in the fake home's hooks dir pointing under REPO_ROOT/.worktrees/nonexistent/ is removed; a pre-seeded dangling symlink pointing to /nonexistent/elsewhere survives; a pre-seeded settings.json containing a dead $HOME/.claude/hooks/ghost/ entry plus an unrelated key ends with the ghost entry gone and the unrelated key and diu-stop entry present.
Non-goals: No change to which hooks install, no Cursor hooks.json or Codex config.toml pruning, no deletion of real files, no change to the engine-only flag, no README or docs edits.
Layer: app_regression
Feature state: active
Files:
- install.sh
- scripts/prune_dead_hook_entries.py
- tests/test_prune_dead_hook_entries.py
- tests/test_install.py
Change types:
- install.sh: modify
- scripts/prune_dead_hook_entries.py: create
- tests/test_prune_dead_hook_entries.py: create
- tests/test_install.py: modify
Acceptance criteria:
- `python3 -m unittest tests.test_prune_dead_hook_entries tests.test_install -v` exits 0 and includes the new cases.
- `python3 scripts/check_ecosystem_boundaries.py` exits 0.
Exit code: 0
…he prune unit tests and the install end-to-end tests pass against a fake home, and the boundary check still passes. Review lane: proof Safety invariant: Verification runs only against a temporary fake $HOME and synthetic settings dictionaries. Effectiveness measurement: The new test cases assert the dangling repo link and dead entry are gone while the outside-repo link and unrelated key remain. Slice rationale: One focused proof for the deletion rules before review. Architectural effect: None; verification only. Goal: Prove the deletion rules delete exactly the dead state and nothing else. Motivation: Deletion code is only trustworthy when its negative cases are exercised. Alternative considerations: Running against the real home was rejected as non-reproducible. Implementation details: Run the two unittest modules and the boundary check. Non-goals: No mutations outside the fake home. Layer: app_regression Feature state: active Acceptance criteria: - Exits 0 only when all checks pass. Exit code: 0
…No ephemeral inter-task handoff files remain in the worktree before the merge gate. Review lane: cleanup Safety invariant: The scrub script only removes known handoff artifact names and never touches source, tests, or other repository files. Effectiveness measurement: The script exits non-zero if any handoff artifact remains. Slice rationale: Required terminal scrub for every implementation workflow. Architectural effect: None; hygiene only. Goal: Leave the branch free of handoff artifacts. Motivation: Handoff files must not reach the PR. Alternative considerations: Manual cleanup was rejected as non-deterministic. Implementation details: Run scripts/scrub-handoff-artifacts.sh. Non-goals: No product edits. Layer: app_regression Feature state: active Acceptance criteria: - `bash scripts/scrub-handoff-artifacts.sh` exits 0. Exit code: 0
…a9456e09a-482483ce — Review claim: No ephemeral inter-task handoff files remain in the worktree before the merge gate. Review lane: cleanup Safety invariant: The scrub script only removes known handoff artifact names and never touches source, tests, or other repository files. Effectiveness measurement: The script exits non-zero if any handoff artifact remains. Slice rationale: Required terminal scrub for every implementation workflow. Architectural effect: None; hygiene only. Goal: Leave the branch free of handoff artifacts. Motivation: Handoff files must not reach the PR. Alternative considerations: Manual cleanup was rejected as non-deterministic. Implementation details: Run scripts/scrub-handoff-artifacts.sh. Non-goals: No product edits. Layer: app_regression Feature state: active Acceptance criteria: - `bash scripts/scrub-handoff-artifacts.sh` exits 0.
Bugbot couldn't run - usage limit reachedBugbot is counted against Cursor usage for this user or team, and this run hit a usage or spend limit. A user or team admin can review and increase usage limits in the Cursor dashboard. (requestId: serverGenReqId_3b36e3d4-9556-43c2-993e-a5833654238b) |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Make every
./install.shrun (both modes) remove dangling symlinks thatpoint into this repo (typically left behind by deleted worktrees) from the
harness hook and skill directories, and remove ~/.claude/settings.json hook
entries whose script file under ~/.claude/hooks/ no longer exists. Depends on
the --engine-only installer slice having merged.
Goal: A machine stops erroring on every Bash call because of a hook whose
worktree was deleted, and nobody has to hand-edit settings.json.
Motivation: Today ~/.claude/hooks holds three links into deleted
.worktrees/ paths and settings.json still calls
~/.claude/hooks/narrow-the-scope/claude_posttooluse.py, which prints an error
on every command.
Safety invariant: Symlinks are removed only when they are dangling and their
raw readlink target starts with "$REPO_DIR/"; settings.json entries are
removed only when the command references a path under $HOME/.claude/hooks/
that does not exist; every other key, matcher, and command is preserved.
Effectiveness measurement: On the maintainer's machine ~/.claude/hooks goes
from 3 dangling repo links to 0 and settings.json from 1 dead hook entry to
0 after one install run.
Test Plan
Test Plan
python3 -m unittest tests.test_prune_dead_hook_entries tests.test_install -v && python3 scripts/check_ecosystem_boundaries.py— Review claim: The prune unit tests and the install end-to-end tests pass against a fake home, and the boundary check still passes.Review lane: proof
Safety invariant: Verification runs only against a temporary fake $HOME and synthetic settings dictionaries.
Effectiveness measurement: The new test cases assert the dangling repo link and dead entry are gone while the outside-repo link and unrelated key remain.
Slice rationale: One focused proof for the deletion rules before review.
Architectural effect: None; verification only.
Goal: Prove the deletion rules delete exactly the dead state and nothing else.
Motivation: Deletion code is only trustworthy when its negative cases are exercised.
Alternative considerations: Running against the real home was rejected as non-reproducible.
Implementation details: Run the two unittest modules and the boundary check.
Non-goals: No mutations outside the fake home.
Layer: app_regression
Feature state: active
Acceptance criteria:
Exits 0 only when all checks pass.
bash scripts/scrub-handoff-artifacts.sh— Review claim: No ephemeral inter-task handoff files remain in the worktree before the merge gate.Review lane: cleanup
Safety invariant: The scrub script only removes known handoff artifact names and never touches source, tests, or other repository files.
Effectiveness measurement: The script exits non-zero if any handoff artifact remains.
Slice rationale: Required terminal scrub for every implementation workflow.
Architectural effect: None; hygiene only.
Goal: Leave the branch free of handoff artifacts.
Motivation: Handoff files must not reach the PR.
Alternative considerations: Manual cleanup was rejected as non-deterministic.
Implementation details: Run scripts/scrub-handoff-artifacts.sh.
Non-goals: No product edits.
Layer: app_regression
Feature state: active
Acceptance criteria:
bash scripts/scrub-handoff-artifacts.shexits 0.Revert Plan
Revert Plan
git revert <sha>Note
Medium Risk
Install mutates user hook symlinks and rewrites ~/.claude/settings.json, but only for dangling repo-owned links and hook commands whose script paths are missing.
Overview
./install.shnow self-heals stale hook state on every run (full and--engine-only): it deletes dangling symlinks in~/.claude|cursor|codexhook and skill dirs whenreadlinkpoints under$REPO_DIR, and runs newscripts/prune_dead_hook_entries.pyto strip~/.claude/settings.jsonhook commands that reference missing files under~/.claude/hooks/, dropping empty matcher groups while leaving other settings intact.Coverage adds
tests/test_prune_dead_hook_entries.pyandTestStaleStatePruneintests/test_install.pyfor the negative cases (outside-repo links, unrelated settings keys).Reviewed by Cursor Bugbot for commit 4a6bab2. Bugbot is set up for automated code reviews on this repo. Configure here.