- factory-audit: no-op hooks, ./ after interpreters, split-quote and
spaced ${PLUGIN_ROOT} paths, camelCase events in Claude-targeted flat
files, case-insensitive routing stems, and non-string YAML keys are
now caught; input: forms and prompt boundary clauses align with
primitive-author; bats 347 -> 367
- primitive-author: routing forms, quoting guidance, install exit on
hidden Unicode, argument-hint exception
- forge: drop duplicated gotcha, fit description and body budgets (#143)
- skill-author: primitive-author boundary, Claude-only env vars
- hook: exit unless CLAUDE_PROJECT_DIR is set, so Copilot/Codex never
run apm update; ADR-0019 correction, ADR-0025 amendment, docs fixes
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KkT7RSDwDbmrM9T34b6sTi
93 lines
5.0 KiB
Bash
Executable File
93 lines
5.0 KiB
Bash
Executable File
#!/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 it, so its absence is the exit (ADR-0019, amendment 2026-09-28).
|
|
[[ -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 nothing was installed through apm here — e.g. a host that
|
|
# installed this plugin natively. 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
|
|
|
|
# `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.
|
|
#
|
|
# 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 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"
|
|
|
|
# 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"
|
|
}
|
|
|
|
# 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
|
|
|
|
if timeout 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
|