Files
holocron/docs/adr/0019-session-start-hook-keeps-the-apm-install-current.md
Defame1297 dee56c506a 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
2026-08-14 17:46:38 +00:00

120 lines
7.8 KiB
Markdown

# A SessionStart hook keeps the apm install current, replacing a git hook that never ran
ADR-0018 switched this repo to consuming its own plugins through `apm install`, with the six
packages declared as unpinned git refs against the holocron remote's default branch. That decision
left a hole it named but did not fill: the deployed content goes stale the moment anyone merges,
and nothing detects it.
**Status: accepted (2026-08-14).**
## Context
The pre-existing answer was `scripts/git-hooks/post-push`, which pulled the marketplace clone and
ran `claude plugin update kyberforge`. Issue #78 filed it as a bug — the hook updated `kyberforge`
but not `gitea`, so gitea skills stayed pinned at a pre-refactor version after #67 merged.
The issue's premise was wrong in a way nobody had noticed for six weeks. **Git has no client-side
`post-push` hook.** `githooks(5)` does not list one, and git 2.39.5 does not invoke one.
`scripts/install.sh` copies every file in `scripts/git-hooks/` into `.git/hooks/`, so
`.git/hooks/post-push` existed on disk and looked installed. It had never fired. The hook did not
skip `gitea`; it skipped everything. Both tests that appeared to cover it — `test-post-push.sh` and
`test-git-hooks-install.sh` — asserted only that the script behaved correctly when invoked directly
and that install.sh copied the file. Neither asserted that git ever runs it.
That also makes the original framing wrong. Refreshing on push assumes the person who pushes is the
person who goes stale, which is backwards: your install goes stale when *someone else* merges, and a
push of your own is neither necessary nor sufficient for it to have happened.
## Decision
A `SessionStart` hook, shipped in `plugins/kyberforge/.apm/hooks/`, checks whether the install is
behind and refreshes it in place.
`SessionStart` is the correct trigger because the thing that goes stale is the skill content a
*session* loads, and that is the moment the staleness does damage. It also enables two things a git
hook structurally cannot do: `additionalContext` puts the notice into the agent's context rather
than terminal scrollback nobody reads, and `reloadSkills: true` makes the host re-scan the skill
directories after the hook returns, so a refresh lands in the running session without a restart.
apm's own lifecycle events (`pre-/post-install`, `pre-/post-update`, `pre-/post-uninstall`) were
rejected: they fire around apm operations already chosen, so they can announce a refresh but never
detect that one is needed.
Three sub-decisions:
- **Refresh automatically rather than report.** The hook runs `apm update --yes` and asks for a skill
reload. The rejected alternative was to report and let a human run it. Auto-refresh costs a
rewritten `apm.lock.yaml` — a committed file — appearing as an unexplained modification in the
working tree, on any branch, at any time. The emitted notice says so explicitly for that reason.
- **`plugins/kyberforge/.apm/hooks/`, not `.claude/settings.json`.** ADR-0018 established that apm
owns `.claude/settings.json` and that any repo-authored key in it is permanent `apm audit --ci`
drift. A hook shipped in a package is written into that file by apm itself, so it is apm's output
and does not drift. `.claude/settings.local.json` also works but is gitignored and machine-local,
which fails the requirement that this travel with the repo.
- **`startup` matcher only.** `resume`, `clear`, `compact` and `fork` would re-run the check on every
compaction, and a compaction is not an event after which the remote can have moved.
The executable-trust gate is switched on at the same time. Root `apm.yml` gains an `executables:`
block allowing kyberforge's hooks and bin.
## Consequences
**The gate is off until something turns it on, and this repo had it off.** `apm approve --list`
reports `Executable-trust gate disabled -- all executables deploy` until an `executables:` block
exists in `apm.yml`. Any hook, bin, or MCP primitive a dependency shipped would have deployed with
no prompt and no record. The block added here closes that for this repo; every other apm project on
this machine still has it open.
**The allow key is version-pinned, and that is a live failure mode.** apm writes
`kyberforge#1.4.1`, not `kyberforge`. A kyberforge version bump makes the entry stop matching, the
gate blocks the hook, and the install silently stops refreshing — the exact failure this ADR exists
to end, reintroduced through the mechanism meant to secure it. The `executables:` block carries a
comment saying to check it first when skills go stale after a release.
**A referenced hook script must be addressed at its `.apm/` path.** apm resolves
`${CLAUDE_PLUGIN_ROOT}/...` against the installed package root, and `apm pack` keeps only `*.json`
from `.apm/hooks/` when it builds the flat mirror. So `${CLAUDE_PLUGIN_ROOT}/hooks/check-apm-current.sh`
resolves to the mirror, where the script does not exist — verified, apm reports
`Hook script not found` and deploys a hook pointing at nothing. The working reference is
`${CLAUDE_PLUGIN_ROOT}/.apm/hooks/check-apm-current.sh`. The script cannot simply be placed in
`plugins/kyberforge/hooks/` either: that directory is `rm -rf`'d by every content sync (ADR-0017).
A test pins the reference.
**Session startup gets slower when the install is stale.** Measured: ~0.7 s for the `apm outdated`
check when everything is current, ~10.4 s when six packages are behind and the refresh runs. The
hook declares `timeout: 320` to cover a cold multi-package fetch.
**The hook cannot install itself.** Dependencies resolve from the remote, so the hook does not
deploy until this change is merged and `apm update` has run once against the new default branch.
Until then the repo has the mechanism in source and not in effect.
**`.claude/settings.json` stops being `{"hooks": {}}`.** apm merges the hook into it and tracks
ownership in a `.claude/apm-hooks.json` sidecar, with the script copied to
`.claude/hooks/<pkg>/`. The sidecar and the script directory are gitignored install output; the
settings file remains committed, now with apm-generated content in it. ADR-0018's statement that the
committed content is exactly `{"hooks": {}}` is superseded on that point only — the rule it was
protecting, that nothing repo-authored goes in that file, is unchanged.
**Native consumers are protected by a guard, not by the gate.** A host installing holocron through
`claude plugin install` auto-discovers `hooks/hooks.json` and does not consult apm's trust gate at
all. The script therefore exits silently when there is no `apm.lock.yaml` in the working directory,
which is what makes it inert in a repo that does not consume packages through apm. Copilot CLI sees
no hook at all, for the reasons already documented in `plugins/kyberforge/docs/hooks.md`.
**`scripts/git-hooks/` is now empty.** `post-push` and `test-post-push.sh` are deleted.
`install.sh`'s copy block is generic and is kept; `test-git-hooks-install.sh` now synthesizes its
own fixture hook instead of depending on a real one existing, so the mechanism stays tested and can
be used again if a hook git actually invokes is ever wanted.
## Alternatives considered
- **A `post-merge` git hook.** Real, unlike `post-push`, and verified to fire on both a
fast-forward `git pull` and a `git pull --rebase`. Rejected as the primary mechanism because a
pull is the wrong signal, and because it cannot reload skills in a running session. It remains
the only option for a project that consumes apm packages without a Claude-family host.
- **Reporting instead of refreshing.** See the sub-decision above.
- **A seventh plugin holding only this hook**, to avoid shipping it to external kyberforge
consumers. Rejected as disproportionate: the `apm.lock.yaml` guard already makes the hook inert
for anyone not consuming through apm, and a package exists to be maintained, versioned, and
registered in the marketplace.