From d576fc217ba304b48dfb1c54b92bc1849397fd9b Mon Sep 17 00:00:00 2001 From: Craig Jennings Date: Thu, 11 Jun 2026 11:32:40 -0500 Subject: fix(install): link default hooks in make install session-clear-resume.sh shipped 2026-06-02 with its settings.json entry, but make install didn't cover hooks and nothing re-ran install-hooks, so the symlink only existed on machines that had linked it by hand. Everywhere else the hook errored silently on every /clear. make install now links DEFAULT_HOOKS alongside skills, rules, config, and bin scripts, so the startup workflow's install step propagates new hooks machine-wide. Opt-in hooks stay manual. scripts/tests/install-hooks-link.bats covers the new section. The SessionStart-on-clear todo task closes with this: the hook feature already existed, and the gap was distribution. --- .ai/workflows/startup.org | 4 +-- Makefile | 17 +++++++-- claude-templates/.ai/workflows/startup.org | 4 +-- scripts/tests/install-hooks-link.bats | 56 ++++++++++++++++++++++++++++++ todo.org | 13 +++++-- 5 files changed, 86 insertions(+), 8 deletions(-) create mode 100644 scripts/tests/install-hooks-link.bats diff --git a/.ai/workflows/startup.org b/.ai/workflows/startup.org index e630460..2fe6827 100644 --- a/.ai/workflows/startup.org +++ b/.ai/workflows/startup.org @@ -48,7 +48,7 @@ Behavior: A skill, rule, or bin script added to rulesets and pushed reaches each machine's *files* on the next pull, but not its =~/.claude= *symlink* — =make install= only links what isn't already linked, and =git pull= doesn't run it. So a newly-added skill stays silently uninstalled until someone re-runs =make install= by hand. The flush skill sat in that gap from 2026-06-02 until a manual install on 2026-06-05. Running =make install= here, right after the rulesets pull, closes it: "add a skill, commit, push" becomes enough for it to reach every machine on the next session. -=make install= is idempotent — it skips every already-linked target, links only what's new, WARNs on a non-symlink collision, and only ever writes symlinks under =~/.claude= and =~/.local/bin=, so it's safe and reversible. It covers skills, rules, claude config, and bin scripts in one pass, so the same step also picks up a newly-added rule or script, not just a skill. +=make install= is idempotent — it skips every already-linked target, links only what's new, WARNs on a non-symlink collision, and only ever writes symlinks under =~/.claude= and =~/.local/bin=, so it's safe and reversible. It covers skills, rules, claude config, default hooks, and bin scripts in one pass, so the same step also picks up a newly-added rule, hook, or script, not just a skill. (Hooks joined the set 2026-06-11 after =session-clear-resume.sh= sat in exactly this gap: its settings.json entry traveled via the tracked symlink on 2026-06-02, but the hook symlink itself only landed where someone ran =make install-hooks= by hand, so the hook errored silently on every =/clear= elsewhere.) #+begin_src bash if [ -d "$HOME/code/rulesets" ]; then @@ -184,7 +184,7 @@ This phase touches the user and runs sequentially: - Briefly note significant template updates noticed during sync (new workflows, protocol changes). - *Task-review nudge.* If the Phase A staleness count (step 11) is greater than zero, surface one line: "== top-level tasks unreviewed for >7 days — say 'let's do a task review' to run a cycle." If zero, say nothing. - *Language-bundle sync.* If the Phase A step-12 call (=sync-language-bundle.sh=) printed anything, surface it. =fixed= lines are informational — the drift was already repaired (note that =.claude/= is now dirty if the project commits it). A =drift= line on =settings.json= is surface-only and needs the printed =make install- PROJECT=.= to reconcile; flag it so the user can decide. If the call was silent, say nothing. - - *Newly-installed symlinks.* If the Phase A.0 =make install= step printed any =link= / =relink= / =WARN= line, surface it. A =link= line means a skill, rule, or script added to rulesets is now linked into =~/.claude= for the first time on this machine. For a newly-linked *skill*, check the agent's available-skills list: if the harness already registered it mid-session, note it's available and move on; if it's absent, stop and tell Craig to restart the agent so it loads (whether a mid-session reload works is harness-version-dependent). A =WARN ... not a symlink= line is a real collision at the target path — surface it; it needs a human. If the step printed only "nothing new to link", say nothing. + - *Newly-installed symlinks.* If the Phase A.0 =make install= step printed any =link= / =relink= / =WARN= line, surface it. A =link= line means a skill, rule, hook, or script added to rulesets is now linked into =~/.claude= for the first time on this machine. For a newly-linked *skill*, check the agent's available-skills list: if the harness already registered it mid-session, note it's available and move on; if it's absent, stop and tell Craig to restart the agent so it loads (whether a mid-session reload works is harness-version-dependent). For a newly-linked *hook*, note that the harness reads hooks at session start — it fires from the next session (or after Craig opens =/hooks= once); its settings.json wiring travels with the tracked file, so the link is usually the only missing piece. A =WARN ... not a symlink= line is a real collision at the target path — surface it; it needs a human. If the step printed only "nothing new to link", say nothing. - *Template-sync churn (safety net).* Check whether Phase A's rsync left uncommitted churn in the synced =.ai/= paths — accumulated from a prior session that crashed before wrap-up, or freshly added this session when rulesets advanced. Without surfacing, it builds up silently until it blocks Phase A.0's auto-ff (git won't ff a dirty tree). Skip in the rulesets repo itself (there =.ai/= is a committed mirror, kept honest by the pre-commit hook). The check is sequential here, after the rsync has finished — not a Phase A step, to keep that batch race-free. #+begin_src bash diff --git a/Makefile b/Makefile index 33c582f..5ff155d 100644 --- a/Makefile +++ b/Makefile @@ -170,8 +170,8 @@ bootstrap: ## First-time machine setup: install + install-hooks + install-mcp ##@ Global install (symlinks into ~/.claude/) -install: ## Symlink skills and rules into ~/.claude/ - @mkdir -p $(SKILLS_DIR) $(RULES_DIR) +install: ## Symlink skills, rules, config, hooks, and bin scripts into place + @mkdir -p $(SKILLS_DIR) $(RULES_DIR) $(HOOKS_DIR) @echo "Skills:" @for skill in $(SKILLS); do \ if [ -L "$(SKILLS_DIR)/$$skill" ]; then \ @@ -220,6 +220,19 @@ install: ## Symlink skills and rules into ~/.claude/ fi \ fi @echo "" + @echo "Hooks (default):" + @for hook in $(DEFAULT_HOOKS); do \ + name=$$(basename $$hook); \ + if [ -L "$(HOOKS_DIR)/$$name" ]; then \ + echo " skip $$name (already linked)"; \ + elif [ -e "$(HOOKS_DIR)/$$name" ]; then \ + echo " WARN $$name exists and is not a symlink — skipping"; \ + else \ + ln -s "$(CURDIR)/$$hook" "$(HOOKS_DIR)/$$name"; \ + echo " link $$name → $(HOOKS_DIR)/$$name"; \ + fi \ + done + @echo "" @echo "bin scripts:" @mkdir -p "$(LOCAL_BIN)" @for src in $(CURDIR)/claude-templates/bin/*; do \ diff --git a/claude-templates/.ai/workflows/startup.org b/claude-templates/.ai/workflows/startup.org index e630460..2fe6827 100644 --- a/claude-templates/.ai/workflows/startup.org +++ b/claude-templates/.ai/workflows/startup.org @@ -48,7 +48,7 @@ Behavior: A skill, rule, or bin script added to rulesets and pushed reaches each machine's *files* on the next pull, but not its =~/.claude= *symlink* — =make install= only links what isn't already linked, and =git pull= doesn't run it. So a newly-added skill stays silently uninstalled until someone re-runs =make install= by hand. The flush skill sat in that gap from 2026-06-02 until a manual install on 2026-06-05. Running =make install= here, right after the rulesets pull, closes it: "add a skill, commit, push" becomes enough for it to reach every machine on the next session. -=make install= is idempotent — it skips every already-linked target, links only what's new, WARNs on a non-symlink collision, and only ever writes symlinks under =~/.claude= and =~/.local/bin=, so it's safe and reversible. It covers skills, rules, claude config, and bin scripts in one pass, so the same step also picks up a newly-added rule or script, not just a skill. +=make install= is idempotent — it skips every already-linked target, links only what's new, WARNs on a non-symlink collision, and only ever writes symlinks under =~/.claude= and =~/.local/bin=, so it's safe and reversible. It covers skills, rules, claude config, default hooks, and bin scripts in one pass, so the same step also picks up a newly-added rule, hook, or script, not just a skill. (Hooks joined the set 2026-06-11 after =session-clear-resume.sh= sat in exactly this gap: its settings.json entry traveled via the tracked symlink on 2026-06-02, but the hook symlink itself only landed where someone ran =make install-hooks= by hand, so the hook errored silently on every =/clear= elsewhere.) #+begin_src bash if [ -d "$HOME/code/rulesets" ]; then @@ -184,7 +184,7 @@ This phase touches the user and runs sequentially: - Briefly note significant template updates noticed during sync (new workflows, protocol changes). - *Task-review nudge.* If the Phase A staleness count (step 11) is greater than zero, surface one line: "== top-level tasks unreviewed for >7 days — say 'let's do a task review' to run a cycle." If zero, say nothing. - *Language-bundle sync.* If the Phase A step-12 call (=sync-language-bundle.sh=) printed anything, surface it. =fixed= lines are informational — the drift was already repaired (note that =.claude/= is now dirty if the project commits it). A =drift= line on =settings.json= is surface-only and needs the printed =make install- PROJECT=.= to reconcile; flag it so the user can decide. If the call was silent, say nothing. - - *Newly-installed symlinks.* If the Phase A.0 =make install= step printed any =link= / =relink= / =WARN= line, surface it. A =link= line means a skill, rule, or script added to rulesets is now linked into =~/.claude= for the first time on this machine. For a newly-linked *skill*, check the agent's available-skills list: if the harness already registered it mid-session, note it's available and move on; if it's absent, stop and tell Craig to restart the agent so it loads (whether a mid-session reload works is harness-version-dependent). A =WARN ... not a symlink= line is a real collision at the target path — surface it; it needs a human. If the step printed only "nothing new to link", say nothing. + - *Newly-installed symlinks.* If the Phase A.0 =make install= step printed any =link= / =relink= / =WARN= line, surface it. A =link= line means a skill, rule, hook, or script added to rulesets is now linked into =~/.claude= for the first time on this machine. For a newly-linked *skill*, check the agent's available-skills list: if the harness already registered it mid-session, note it's available and move on; if it's absent, stop and tell Craig to restart the agent so it loads (whether a mid-session reload works is harness-version-dependent). For a newly-linked *hook*, note that the harness reads hooks at session start — it fires from the next session (or after Craig opens =/hooks= once); its settings.json wiring travels with the tracked file, so the link is usually the only missing piece. A =WARN ... not a symlink= line is a real collision at the target path — surface it; it needs a human. If the step printed only "nothing new to link", say nothing. - *Template-sync churn (safety net).* Check whether Phase A's rsync left uncommitted churn in the synced =.ai/= paths — accumulated from a prior session that crashed before wrap-up, or freshly added this session when rulesets advanced. Without surfacing, it builds up silently until it blocks Phase A.0's auto-ff (git won't ff a dirty tree). Skip in the rulesets repo itself (there =.ai/= is a committed mirror, kept honest by the pre-commit hook). The check is sequential here, after the rsync has finished — not a Phase A step, to keep that batch race-free. #+begin_src bash diff --git a/scripts/tests/install-hooks-link.bats b/scripts/tests/install-hooks-link.bats new file mode 100644 index 0000000..80ac8dd --- /dev/null +++ b/scripts/tests/install-hooks-link.bats @@ -0,0 +1,56 @@ +#!/usr/bin/env bats +# make install must link default hooks into HOOKS_DIR. The gap this guards: +# install-hooks was a separate target nobody re-ran, so a hook added to the +# repo (session-clear-resume.sh, 2026-06-02) reached settings.json on every +# machine via the tracked symlink but its ~/.claude/hooks/ link never landed, +# and the hook errored silently on every /clear until a manual install. + +setup() { + REPO_ROOT="$(cd "$(dirname "$BATS_TEST_FILENAME")/../.." && pwd)" + TMPHOME="$(mktemp -d)" +} + +teardown() { + rm -rf "$TMPHOME" +} + +run_install() { + make -C "$REPO_ROOT" install \ + SKILLS_DIR="$TMPHOME/skills" \ + RULES_DIR="$TMPHOME/rules" \ + HOOKS_DIR="$TMPHOME/hooks" \ + CLAUDE_DIR="$TMPHOME/claude" \ + LOCAL_BIN="$TMPHOME/bin" +} + +@test "install links default hooks into HOOKS_DIR" { + run run_install + [ "$status" -eq 0 ] + [ -L "$TMPHOME/hooks/session-clear-resume.sh" ] + [ -L "$TMPHOME/hooks/precompact-priorities.sh" ] +} + +@test "install does not link opt-in hooks" { + run run_install + [ "$status" -eq 0 ] + [ ! -e "$TMPHOME/hooks/destructive-bash-confirm.py" ] +} + +@test "install is idempotent on hooks (second run skips, link survives)" { + run run_install + [ "$status" -eq 0 ] + run run_install + [ "$status" -eq 0 ] + [[ "$output" == *"skip session-clear-resume.sh (already linked)"* ]] + [ -L "$TMPHOME/hooks/session-clear-resume.sh" ] +} + +@test "install warns on a non-symlink collision and leaves the file alone" { + mkdir -p "$TMPHOME/hooks" + echo "real file" > "$TMPHOME/hooks/session-clear-resume.sh" + run run_install + [ "$status" -eq 0 ] + [[ "$output" == *"WARN session-clear-resume.sh exists and is not a symlink"* ]] + [ ! -L "$TMPHOME/hooks/session-clear-resume.sh" ] + grep -q "real file" "$TMPHOME/hooks/session-clear-resume.sh" +} diff --git a/todo.org b/todo.org index 30fd7e0..bae9d09 100644 --- a/todo.org +++ b/todo.org @@ -42,14 +42,23 @@ The org-table standard keeps project-doc tables <=120 cols with multi-line wrapp Out of a work-project handoff 2026-06-09. -** TODO [#C] SessionStart-on-clear hook for auto-resume :feature: +** DONE [#C] SessionStart-on-clear hook for auto-resume :feature: +CLOSED: [2026-06-11 Thu] :PROPERTIES: -:LAST_REVIEWED: 2026-06-10 +:LAST_REVIEWED: 2026-06-11 :END: Add a SessionStart hook (matcher: clear) in settings.json that auto-injects "read .ai/session-context.org and resume if present, else run startup.org". Today /flush prompts the user to /clear and the next session relies on the model re-reading session-context; the hook makes resume automatic on /clear. Keep full startup.org for genuine fresh starts (new day, other machine, been away). Likely lands as claude-templates workflow notes plus the hook in settings.json. The checkpoint+resume halves already shipped as /flush. This is the remaining automation piece. Out of a work-project handoff 2026-06-09 (process tooling, belongs in rulesets not the work project). +Resolution 2026-06-11: the hook itself had already shipped 2026-06-02 (hooks/session-clear-resume.sh + the SessionStart clear entry in the tracked settings.json — this task duplicated it). What was actually broken: make install didn't cover hooks, so the symlink never reached machines that hadn't run make install-hooks by hand, and the hook errored silently on every /clear. Fixed by folding default-hook linking into make install (startup's Phase A.0 now propagates hooks machine-wide), with bats coverage in scripts/tests/install-hooks-link.bats. Both hook branches verified on ratio; the live /clear fire is a one-keystroke manual test. +*** TODO Manual testing and validation :test: +**** /clear mid-session resumes from the anchor +What we're verifying: the SessionStart(clear) hook fires and the fresh context resumes instead of cold-starting. +- In any project session with a live .ai/session-context.org (this rulesets session qualifies), type /clear +- Send any short message (the injected context loads but the model waits for your next keystroke) +Expected: the reply starts with "flushed." on its own line, restates the Active Goal and immediate Next Step, and does NOT run the startup workflow. + ** DOING [#C] Check that memories are sync'd across machines via git :spec: :PROPERTIES: :LAST_REVIEWED: 2026-06-10 -- cgit v1.2.3