Why
---
The SessionStart freshness hook is a new kyberforge feature and was committed
without a version bump. It also cannot ship unbumped: apm's executable-trust
allow key is version-pinned, so `kyberforge#1.4.1` would have stopped matching
the moment the package version moved, silently blocking the very hook the entry
exists to authorise.
Implementation notes
-----------------
kyberforge 1.4.1 -> 1.5.0 (MINOR, new feature); marketplace and root manifest
0.3.4 -> 0.4.0, following the convention in bb9158d where a package bump carries
the marketplace version with it. The executables.allow key moves to
kyberforge#1.5.0 in the same commit. Compiled manifests regenerated with
`apm pack` plus both sync scripts.
A bare `apm pack` also writes build/ and a root .claude-plugin/plugin.json,
neither of which is repo content — the pre-push gate only ever runs pack with
--dry-run, so they had not appeared before. Both removed; build/ is now
gitignored so a future release does not stage it by accident.
Impact
------
Consumers pinning kyberforge see a MINOR bump. All 15 pre-push hooks pass,
including apm pack --check-clean, so compiled output matches the manifests.
ADR: 0019
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01X7GvKuJfy2WrdBmUttV4DT
122 lines
8.0 KiB
Markdown
122 lines
8.0 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.5.0`, not `kyberforge` — and the release that ships this hook proved the point
|
|
immediately, since bumping kyberforge to 1.5.0 required editing the key in the same commit. 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.
|