feat(kyberforge): refresh the apm install at SessionStart, not at push

Why
---
ADR-0018 left deployed skills tracking the remote default branch with nothing
watching for drift. The mechanism that was supposed to cover this,
scripts/git-hooks/post-push, could never have worked: git has no client-side
post-push hook. install.sh copied it into .git/hooks/ so it looked installed,
and it had never once fired. Issue #78 reported it as skipping the gitea
plugin; it was skipping everything.

Refreshing on push was also the wrong shape. Your install goes stale when
someone else merges, so a push of your own is neither necessary nor sufficient
for staleness to have occurred.

Implementation notes
--------------------
kyberforge ships a SessionStart hook (startup matcher only) that runs
`apm outdated`, and when anything is behind runs `apm update --yes` and returns
reloadSkills:true so the running session picks up redeployed content. It exits
silently with no apm.lock.yaml present, which keeps it inert for hosts that
installed this plugin natively rather than through apm.

Two findings drove the wiring, both verified rather than assumed:

- apm resolves ${CLAUDE_PLUGIN_ROOT} against the installed package root, and
  `apm pack` keeps only *.json from .apm/hooks/. A .../hooks/<script> reference
  therefore points into the generated mirror where the script does not exist —
  apm reports "Hook script not found" and deploys a hook aimed at nothing. The
  reference must be .apm/-relative, and a test pins it.
- apm's executable-trust gate is OFF unless apm.yml carries an `executables:`
  block; until now every hook, bin and MCP primitive a dependency shipped would
  have deployed unprompted. Root apm.yml now enables it. The allow key is
  version-pinned by apm's design, so a kyberforge version bump silently blocks
  the hook until the key is bumped too — called out in the block and the ADR.

Also corrects ADR-0018 and AGENTS.md, which named `apm install` as the refresh
command. It is not: `apm install` deploys from apm.lock.yaml's pinned commit
and does not re-resolve refs. `apm update` does.

Impact
------
Session startup costs ~0.7s when current and ~10.4s when six packages are
behind. Auto-refresh rewrites apm.lock.yaml, so an unexplained modification to
it after opening a session is expected; the emitted notice says so.

.claude/settings.json stops being exactly {"hooks": {}} once the hook lands
there — the merged entry is apm's own output, and the rule that nothing
repo-authored goes in that file is unchanged. .claude/hooks/ and the
.claude/apm-hooks.json sidecar are gitignored install output.

The hook cannot install itself: dependencies resolve from the remote, so it
takes effect only after this merges and `apm update` runs once against the new
default branch.

scripts/git-hooks/ is now empty. install.sh's copy block is kept and
test-git-hooks-install.sh synthesizes its own fixture, so the mechanism stays
tested without requiring a dead hook to exist.

ADR: 0019
Refs: #78

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01X7GvKuJfy2WrdBmUttV4DT
This commit is contained in:
2026-08-14 17:46:38 +00:00
parent 2e8732a8e5
commit dee56c506a
14 changed files with 456 additions and 146 deletions

159
tests/test-apm-current-hook.sh Executable file
View File

@@ -0,0 +1,159 @@
#!/usr/bin/env bash
# Tests for plugins/kyberforge/.apm/hooks/check-apm-current.sh — the SessionStart
# hook that keeps an apm-consumed install level with its remote.
#
# `apm` is mocked throughout: the hook's contract is "read `apm outdated`, decide,
# emit SessionStart JSON", and that is testable without a network or a real
# install.
set -euo pipefail
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
HOOK="$REPO_ROOT/plugins/kyberforge/.apm/hooks/check-apm-current.sh"
HOOKS_JSON="$REPO_ROOT/plugins/kyberforge/.apm/hooks/hooks.json"
PASS=0
FAIL=0
pass() { echo " PASS: $1"; PASS=$((PASS + 1)); }
fail() { echo " FAIL: $1"; FAIL=$((FAIL + 1)); }
command -v python3 > /dev/null 2>&1 || { echo "python3 required"; exit 77; }
FAKE_BIN="$(mktemp -d)"
WORK="$(mktemp -d)"
trap 'rm -rf "$FAKE_BIN" "$WORK"' EXIT
# Mock `apm`. $1 chooses what `apm outdated` reports; $2 the exit code of
# `apm update`. A sentinel file records whether update was actually invoked.
make_apm() {
local outdated_line="$1" update_exit="$2"
cat > "$FAKE_BIN/apm" << EOF
#!/usr/bin/env bash
case "\$1" in
outdated) echo "$outdated_line"; exit 0 ;;
update) touch "$WORK/update-was-called"; exit $update_exit ;;
esac
exit 0
EOF
chmod +x "$FAKE_BIN/apm"
}
run_hook() { (cd "$WORK" && PATH="$FAKE_BIN:$PATH" bash "$HOOK" 2>/dev/null); }
json_field() { python3 -c 'import json,sys; print(json.load(sys.stdin)["hookSpecificOutput"][sys.argv[1]])' "$1"; }
# ---------------------------------------------------------------------------
echo "--- inert without an apm-consumed install ---"
# ---------------------------------------------------------------------------
make_apm "[!] 6 outdated dependencies found" 0
rm -f "$WORK/apm.lock.yaml" "$WORK/update-was-called"
out="$(run_hook)"; rc=$?
[[ $rc -eq 0 ]] && pass "exits 0 with no apm.lock.yaml" || fail "should exit 0 with no apm.lock.yaml"
[[ -z "$out" ]] && pass "emits nothing with no apm.lock.yaml" || fail "should stay silent with no apm.lock.yaml"
[[ ! -f "$WORK/update-was-called" ]] && pass "does not run apm update with no apm.lock.yaml" \
|| fail "must not touch a project that does not use apm"
# A native (non-apm) install of this plugin hits exactly this path, so it is the
# guard that keeps the hook from acting on someone else's repo.
touch "$WORK/apm.lock.yaml"
# ---------------------------------------------------------------------------
echo ""
echo "--- inert when apm is absent ---"
# ---------------------------------------------------------------------------
rm -f "$WORK/update-was-called"
out="$( (cd "$WORK" && PATH="$(dirname "$(command -v bash)")" bash "$HOOK" 2>/dev/null) )"; rc=$?
[[ $rc -eq 0 ]] && pass "exits 0 when apm is not on PATH" || fail "should exit 0 when apm is missing"
[[ -z "$out" ]] && pass "emits nothing when apm is not on PATH" || fail "should stay silent when apm is missing"
# ---------------------------------------------------------------------------
echo ""
echo "--- install already current ---"
# ---------------------------------------------------------------------------
make_apm "[*] All dependencies are up-to-date" 0
rm -f "$WORK/update-was-called"
out="$(run_hook)"; rc=$?
[[ $rc -eq 0 ]] && pass "exits 0 when current" || fail "should exit 0 when current"
[[ -z "$out" ]] && pass "emits nothing when current" || fail "should stay silent when current"
[[ ! -f "$WORK/update-was-called" ]] && pass "does not run apm update when current" \
|| fail "must not update when nothing is stale"
# ---------------------------------------------------------------------------
echo ""
echo "--- stale, refresh succeeds ---"
# ---------------------------------------------------------------------------
make_apm "[!] 6 outdated dependencies found" 0
rm -f "$WORK/update-was-called"
out="$(run_hook)"; rc=$?
[[ $rc -eq 0 ]] && pass "exits 0 when stale" || fail "should exit 0 when stale"
[[ -f "$WORK/update-was-called" ]] && pass "runs apm update when stale" || fail "should run apm update when stale"
if echo "$out" | python3 -m json.tool > /dev/null 2>&1; then
pass "emits valid JSON"
[[ "$(echo "$out" | json_field hookEventName)" == "SessionStart" ]] \
&& pass "declares hookEventName SessionStart" || fail "wrong hookEventName"
[[ "$(echo "$out" | json_field reloadSkills)" == "True" ]] \
&& pass "asks the host to reload skills after a successful refresh" || fail "reloadSkills should be true"
echo "$out" | json_field additionalContext | grep -q "6 package" \
&& pass "reports the stale package count" || fail "should report the count"
# The lockfile rewrite is the surprising part of auto-updating; the notice has
# to say so or a dirty worktree looks like something else went wrong.
echo "$out" | json_field additionalContext | grep -q "apm.lock.yaml" \
&& pass "warns that apm.lock.yaml was rewritten" || fail "should warn about the lockfile rewrite"
else
fail "emits valid JSON"
fi
# ---------------------------------------------------------------------------
echo ""
echo "--- stale, refresh fails ---"
# ---------------------------------------------------------------------------
make_apm "[!] 3 outdated dependencies found" 1
out="$(run_hook)"; rc=$?
[[ $rc -eq 0 ]] && pass "exits 0 when the refresh fails" || fail "must never fail the session start"
if echo "$out" | python3 -m json.tool > /dev/null 2>&1; then
pass "emits valid JSON on failure"
[[ "$(echo "$out" | json_field reloadSkills)" == "False" ]] \
&& pass "does not ask for a skill reload when nothing was deployed" || fail "reloadSkills should be false"
echo "$out" | json_field additionalContext | grep -q "apm update" \
&& pass "tells the reader how to refresh by hand" || fail "should name the manual command"
else
fail "emits valid JSON on failure"
fi
# ---------------------------------------------------------------------------
echo ""
echo "--- unparseable count degrades instead of breaking the JSON ---"
# ---------------------------------------------------------------------------
make_apm "[!] lots of outdated dependencies found" 0
out="$(run_hook)"
echo "$out" | python3 -m json.tool > /dev/null 2>&1 \
&& pass "still emits valid JSON when the count cannot be parsed" || fail "JSON broke on an unparseable count"
# ---------------------------------------------------------------------------
echo ""
echo "--- hooks.json wiring ---"
# ---------------------------------------------------------------------------
# apm resolves script paths relative to the package root, and `apm pack` keeps
# only *.json from .apm/hooks/ — so a ${CLAUDE_PLUGIN_ROOT}/hooks/... reference
# points at a directory the script never reaches. It must be .apm/-relative.
referenced="$(python3 -c 'import json,sys; d=json.load(open(sys.argv[1])); print(d["hooks"]["SessionStart"][0]["hooks"][0]["command"])' "$HOOKS_JSON")"
[[ "$referenced" == '${CLAUDE_PLUGIN_ROOT}/.apm/hooks/check-apm-current.sh' ]] \
&& pass "hooks.json references the script at its .apm/ path" \
|| fail "hooks.json references '$referenced' — must be \${CLAUDE_PLUGIN_ROOT}/.apm/hooks/check-apm-current.sh"
[[ -x "$HOOK" ]] && pass "hook script is executable" || fail "hook script must be executable"
matcher="$(python3 -c 'import json,sys; d=json.load(open(sys.argv[1])); print(d["hooks"]["SessionStart"][0]["matcher"])' "$HOOKS_JSON")"
[[ "$matcher" == "startup" ]] && pass "fires on startup only" \
|| fail "matcher is '$matcher' — resume/clear/compact would re-run this every compaction"
# ---------------------------------------------------------------------------
echo ""
echo "Results: $PASS passed, $FAIL failed"
[[ $FAIL -eq 0 ]]

View File

@@ -40,11 +40,25 @@ DEPLOY_EXECUTABLES=()
DEPLOY_DIRS=()
EOF
# Copy the real install.sh and git-hooks into the temp repo
# Copy the real install.sh into the temp repo.
cp "$REPO_ROOT/scripts/install.sh" "$TEMP_REPO/scripts/install.sh"
cp -r "$REPO_ROOT/scripts/git-hooks" "$TEMP_REPO/scripts/git-hooks"
HOOKS_SRC="$TEMP_REPO/scripts/git-hooks"
mkdir -p "$HOOKS_SRC"
# Copy whatever real hooks exist, then add a synthetic fixture. The repo
# currently ships none — the only entry was `post-push`, removed once it was
# found that git has no such client-side hook, so it had never fired (see
# ADR-0019). install.sh's copy block is generic and stays worth testing, so the
# fixture keeps that coverage alive independently of whether any real hook
# happens to exist. Real hooks are still picked up by the loops below.
if [[ -d "$REPO_ROOT/scripts/git-hooks" ]]; then
find "$REPO_ROOT/scripts/git-hooks" -maxdepth 1 -type f -exec cp {} "$HOOKS_SRC/" \;
fi
FIXTURE_HOOK="fixture-hook"
printf '#!/usr/bin/env bash\nexit 0\n' > "$HOOKS_SRC/$FIXTURE_HOOK"
chmod +x "$HOOKS_SRC/$FIXTURE_HOOK"
run_install() {
HOME="$TEMP_HOME" bash "$TEMP_REPO/scripts/install.sh" > /dev/null 2>&1
@@ -108,7 +122,7 @@ OTHER_REPO="$(mktemp -d)"
git -C "$OTHER_REPO" init -q
if HOME="$TEMP_HOME" GIT_DIR="$OTHER_REPO/.git" GIT_WORK_TREE="$OTHER_REPO" \
bash "$TEMP_REPO/scripts/install.sh" > /dev/null 2>&1; then
if [[ -f "$TEMP_REPO/.git/hooks/post-push" ]]; then
if [[ -f "$TEMP_REPO/.git/hooks/$FIXTURE_HOOK" ]]; then
pass "resolves \$TEMP_REPO/.git/hooks/ even with inherited GIT_DIR"
else
fail "installed into inherited GIT_DIR instead of \$TEMP_REPO"

View File

@@ -1,108 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
PASS=0
FAIL=0
pass() { echo " PASS: $1"; PASS=$((PASS + 1)); }
fail() { echo " FAIL: $1"; FAIL=$((FAIL + 1)); }
HOOK="$REPO_ROOT/scripts/git-hooks/post-push"
FAKE_BIN="$(mktemp -d)"
FAKE_HOME="$(mktemp -d)"
trap 'rm -rf "$FAKE_BIN" "$FAKE_HOME"' EXIT
# Helper: write a fake git stub that exits with the given code
make_git() {
local exit_code="$1"
printf '#!/usr/bin/env bash\nexit %s\n' "$exit_code" > "$FAKE_BIN/git"
chmod +x "$FAKE_BIN/git"
}
# Helper: write a fake claude stub; optionally touches a sentinel file on invocation
make_claude() {
local exit_code="$1"
local log="${2:-}"
if [[ -n "$log" ]]; then
printf '#!/usr/bin/env bash\ntouch "%s"\nexit %s\n' "$log" "$exit_code" > "$FAKE_BIN/claude"
else
printf '#!/usr/bin/env bash\nexit %s\n' "$exit_code" > "$FAKE_BIN/claude"
fi
chmod +x "$FAKE_BIN/claude"
}
# Run the hook with mocked PATH and HOME; suppress all output
run_hook() {
PATH="$FAKE_BIN:$PATH" HOME="$FAKE_HOME" bash "$HOOK" > /dev/null 2>&1
}
# Run the hook and capture combined stdout+stderr
capture_hook() {
PATH="$FAKE_BIN:$PATH" HOME="$FAKE_HOME" bash "$HOOK" 2>&1 || true
}
# ---------------------------------------------------------------------------
echo "--- post-push: always exits 0 ---"
# ---------------------------------------------------------------------------
make_git 1; make_claude 0
if run_hook; then
pass "exits 0 when git pull fails"
else
fail "should exit 0 when git pull fails"
fi
make_git 0; make_claude 1
if run_hook; then
pass "exits 0 when claude plugin update fails"
else
fail "should exit 0 when claude plugin update fails"
fi
# ---------------------------------------------------------------------------
echo ""
echo "--- post-push: success output ---"
# ---------------------------------------------------------------------------
make_git 0; make_claude 0
output=$(capture_hook)
echo "$output" | grep -q "kyberforge cache updated" \
&& pass "prints success message when both git pull and claude succeed" \
|| fail "should print success message when both commands succeed"
# ---------------------------------------------------------------------------
echo ""
echo "--- post-push: warnings on failure ---"
# ---------------------------------------------------------------------------
make_git 1; make_claude 0
output=$(capture_hook)
echo "$output" | grep -qi "failed to pull" \
&& pass "warns when git pull fails" \
|| fail "should warn when git pull fails"
make_git 0; make_claude 1
output=$(capture_hook)
echo "$output" | grep -qi "failed" \
&& pass "warns when claude plugin update fails" \
|| fail "should warn when claude plugin update fails"
# ---------------------------------------------------------------------------
echo ""
echo "--- post-push: claude not called when git pull fails ---"
# ---------------------------------------------------------------------------
CLAUDE_LOG="$FAKE_HOME/claude-was-called"
make_git 1; make_claude 0 "$CLAUDE_LOG"
run_hook
if [[ ! -f "$CLAUDE_LOG" ]]; then
pass "claude not called when git pull fails"
else
fail "claude should not be called when git pull fails"
fi
# ---------------------------------------------------------------------------
echo ""
echo "Results: $PASS passed, $FAIL failed"
[[ $FAIL -eq 0 ]]