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

View File

@@ -0,0 +1,42 @@
#!/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 in any project that does not consume packages through apm.
set -uo pipefail
# No lockfile means nothing was installed through apm here — e.g. a host that
# installed this plugin natively. Say nothing and cost nothing.
[[ -f apm.lock.yaml ]] || exit 0
command -v apm > /dev/null 2>&1 || 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.
outdated_output="$(timeout 60 apm outdated 2>&1)" || exit 0
grep -q "outdated dependencies found" <<< "$outdated_output" || exit 0
stale_count="$(grep -oE '[0-9]+ outdated dependencies 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"
}
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 - commit it or discard it deliberately."
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

View File

@@ -1,3 +1,16 @@
{
"hooks": {}
"hooks": {
"SessionStart": [
{
"hooks": [
{
"command": "${CLAUDE_PLUGIN_ROOT}/.apm/hooks/check-apm-current.sh",
"timeout": 320,
"type": "command"
}
],
"matcher": "startup"
}
]
}
}