#!/usr/bin/env bash # SessionStart: keep an apm-consumed install level with its remote. # # Packages declared as unpinned git refs resolve against the remote default # branch, so the deployed .claude/skills/ and .claude/agents/ go stale the # moment anyone merges. The staleness bites when a session loads skills, which # is why this runs at SessionStart rather than off a git hook — a pull is # neither necessary nor sufficient for the install to have drifted. # # Refreshes in place and asks the host to re-scan, so the running session picks # the new content up without a restart. # # Inert under any host but Claude Code, and in any project that does not # consume packages through apm. set -uo pipefail # Claude Code only. apm deploys this hook to Copilot and Codex too, and there # the lockfile guard below would pass — apm wrote the lock — so without this # guard a non-Claude session start would run `apm update --yes` and rewrite the # working tree with nothing to re-scan it. Claude Code exports # CLAUDE_PROJECT_DIR for SessionStart hooks and the other targets do not # document setting it, so its absence is the exit (ADR-0019, correction # 2026-09-28). A heuristic: if the variable is inherited from the user's # environment, a non-Claude session start gets past this guard. [[ -n "${CLAUDE_PROJECT_DIR:-}" ]] || exit 0 # Anchor on the project root, not the session's cwd: a session opened in a # subdirectory would otherwise miss the lockfile, no-op silently, and — worse — # run the apm calls below against that wrong directory. project_dir="$CLAUDE_PROJECT_DIR" # No lockfile means this project consumes nothing through apm, so there is # nothing for apm update to refresh. Say nothing and cost nothing. [[ -f "$project_dir/apm.lock.yaml" ]] || exit 0 command -v apm > /dev/null 2>&1 || exit 0 # Every apm call below must see the same directory the guard just checked — # `apm outdated` and `apm update` both resolve the lockfile from the cwd. cd "$project_dir" || exit 0 # Only ever emit fixed text plus a digit-checked count — never interpolate # command output into the JSON, which would need escaping this cannot do safely. emit() { printf '{"hookSpecificOutput":{"hookEventName":"SessionStart","reloadSkills":%s,"additionalContext":"%s"}}\n' "$1" "$2" } # Every apm call is time-boxed, so timeout(1) is a hard requirement. It is GNU # coreutils: stock macOS has none, and Homebrew's coreutils installs it as # `gtimeout`. Calling a missing binary would exit 127, which the `|| exit 0` # below swallows — the hook would silently never work. Say so once instead, and # do not run apm unbounded. if command -v timeout > /dev/null 2>&1; then timeout_bin="timeout" elif command -v gtimeout > /dev/null 2>&1; then timeout_bin="gtimeout" else emit false "The apm install currency check did not run: neither timeout nor gtimeout (GNU coreutils) is on PATH, and this hook will not run apm without a time limit. Install coreutils (macOS: brew install coreutils) or run apm outdated by hand." exit 0 fi # apm drives git for every remote ref. A remote that wants credentials must fail # fast, not block on a terminal prompt nobody can see until the timeout fires. export GIT_TERMINAL_PROMPT=0 # `apm outdated` exits 0 whether or not anything is stale, so the answer has to # come from its output. ~0.7s against six remote refs; a hung remote must not # hold the session open. -k sends SIGKILL a grace period after the SIGTERM, so a # child that ignores TERM cannot outlive its budget; tests/test-apm-current-hook.sh # sums every limit plus its grace against the hooks.json timeout. # # There is no --json/machine-readable flag on `apm outdated` (verified against # apm 0.28.0), so the phrase match is forced rather than chosen. Note the # singular: apm prints "1 outdated dependency found" when exactly one package is # behind, so matching only "dependencies" would silently miss a one-package # drift. tests/test-apm-current-hook.sh pins both spellings against the real apm. outdated_output="$("$timeout_bin" -k 5 60 apm outdated 2>&1)" || exit 0 grep -qE 'outdated dependenc(y|ies) found' <<< "$outdated_output" || exit 0 stale_count="$(grep -oE '[0-9]+ outdated dependenc(y|ies) found' <<< "$outdated_output" | grep -oE '^[0-9]+' || true)" [[ "$stale_count" =~ ^[0-9]+$ ]] || stale_count="some" # What to do with the rewritten lock depends on the branch (ADR-0019): on the # default branch it is a real update to commit or discard; on a feature branch it # is churn unrelated to the branch and should be discarded. The branch name only # selects between fixed strings and is never interpolated. Outside a git checkout, # or on a detached HEAD, the neutral advice stands. # # So does an UNSET origin/HEAD, which is the common state: git only writes it on # clone, and `git remote add` never does. The fallback here used to be `main`, # which is a guess, and it is wrong in exactly the repos that would notice — a # checkout whose default branch is `master` was told "this is a feature branch, # so discard it" while standing on its default branch, i.e. told to throw away a # real lock update. There is no cheap way to learn the remote's default without # the network, so nothing is asserted: the advice stays neutral and the reader # decides. lock_advice="commit it or discard it deliberately." current_branch="$(git symbolic-ref --short -q HEAD 2> /dev/null || true)" default_branch="$(git symbolic-ref --short -q refs/remotes/origin/HEAD 2> /dev/null || true)" default_branch="${default_branch#origin/}" if [[ -n "$current_branch" && -n "$default_branch" ]]; then if [[ "$current_branch" == "$default_branch" ]]; then lock_advice="this is the default branch, so commit it or discard it deliberately." else lock_advice="this is a feature branch, so discard it: git checkout -- apm.lock.yaml && apm install" fi fi # Two sessions started together would both run `apm update --yes` over the same # tree. Serialise on a lock under apm_modules/: `apm install` itself adds that # directory to .gitignore, so the lock never shows up as a working-tree change, # and apm only ever removes package directories inside it, never the directory # (or this file) itself. The loser does not wait — the winner's refresh is the # one it wanted — and says so. flock(1) is util-linux, absent on stock macOS, # and there the refresh runs unserialised, as it did before the lock existed; so # does a checkout with no apm_modules/ yet, rather than creating it. if command -v flock > /dev/null 2>&1 && [[ -d apm_modules ]] \ && { exec 9> apm_modules/.kyberforge-apm-update.lock; } 2> /dev/null; then if ! flock -n 9; then emit false "apm install is ${stale_count} package(s) behind the remote default branch, and another session is refreshing it right now, so this session skipped its own refresh. Skills and agents loaded in this session may be stale; if they are, restart the session once that refresh has finished." exit 0 fi fi if "$timeout_bin" -k 5 300 apm update --yes > /dev/null 2>&1; then emit true "apm install was ${stale_count} package(s) behind the remote default branch and has been refreshed automatically; skills and agents were redeployed and re-scanned. apm.lock.yaml has been rewritten and is now a modified file in the working tree - ${lock_advice}" else emit false "apm install is ${stale_count} package(s) behind the remote default branch and the automatic refresh failed. Deployed skills and agents may be stale. Run: apm update --yes" fi exit 0