Files
holocron/plugins/kyberforge/docs/hooks.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

9.3 KiB

Hooks

Reference for this plugin's hook definitions: where to edit them, where they end up, and what the host reads.

This document lives in docs/ rather than next to the hooks it describes. plugins/kyberforge/hooks/ is a generated mirror — scripts/sync-plugin-content.sh runs rm -rf on it before every rebuild, so any hand-written file placed there is deleted on the next sync with no drift warning (a prior copy of this document was lost exactly that way). See ADR-0017.

Where to edit

Author hooks in plugins/kyberforge/.apm/hooks/*.json. apm pack --format plugin merges every file in that directory into a single hooks.json, which sync-plugin-content.sh copies to plugins/kyberforge/hooks/hooks.json — the path Claude Code convention-scans. Never edit the mirrored file; the check-plugin-content-sync pre-push hook reports it as drift.

Claude Code structure

hooks/hooks.json is read by Claude Code. Structure:

{
  "hooks": {
    "PostToolUse": [
      {
        "matcher": "Bash",
        "hooks": [
          { "type": "command", "command": "echo 'tool used'" }
        ]
      }
    ]
  }
}

Events (partial list): PreToolUse, PostToolUse, Notification, Stop, and SessionStart (verified end-to-end by the hook below). Claude Code's plugin hook set is larger — SessionEnd, UserPromptSubmit, PreCompact and SubagentStop also exist — and this repo's vendored corpus does not enumerate it anywhere: docs/research/docs/claude-code-plugins/configuration.md:100 describes the file as "Event handlers (PreToolUse, PostToolUse, etc.)", and agent-definition.md:53 covers only the per-agent hooks field, not the plugin-level set. Treat the five names above as the ones this repo has verified, not as the schema. Check Claude Code's own hooks documentation before wiring an event not listed here.

Referencing a script — use the .apm/ path, not the mirror

Use ${CLAUDE_PLUGIN_ROOT} to reference scripts inside this plugin; the plugin runs from a cache or apm_modules/ path after install, not its original repo location. Address the script at its .apm/ path:

"command": "${CLAUDE_PLUGIN_ROOT}/.apm/hooks/check-apm-current.sh"

The obvious-looking ${CLAUDE_PLUGIN_ROOT}/hooks/check-apm-current.sh does not work, and fails quietly enough to be worth spelling out. apm resolves the placeholder against the installed package root, where hooks/ is the generated mirror — and apm pack merges only *.json out of .apm/hooks/, dropping every non-JSON file. So the mirror contains hooks.json and nothing else. apm prints Hook script not found: .../hooks/check-apm-current.sh and then deploys the hook anyway, pointing at a path with no file behind it.

Nor can the script be hand-placed in plugins/kyberforge/hooks/ to satisfy that path: sync-plugin-content.sh runs rm -rf on the directory before every rebuild (ADR-0017), so it would be deleted on the next sync with no drift warning — the same trap that ate this document's predecessor.

tests/test-apm-current-hook.sh pins the reference so a well-meaning "simplification" back to hooks/ fails the suite rather than silently disabling the hook.

Deployed shape

At install, apm merges the event bindings into .claude/settings.json, copies the referenced script to .claude/hooks/<pkg>/ (preserving its executable bit, preserving the .apm/hooks/ subpath), and rewrites command to a ${CLAUDE_PROJECT_DIR}-relative path. Ownership of its own entries is tracked in a .claude/apm-hooks.json sidecar, so an uninstall removes them without touching hand-authored hooks. Both .claude/hooks/ and the sidecar are gitignored install output.

Note that apm's executable-trust gate is off unless the consuming project's apm.yml has an executables: block — without one, package hooks deploy with no prompt. See ADR-0019.

The SessionStart hook

check-apm-current.sh keeps an apm-consumed install level with its remote: it runs apm outdated, and if anything is behind, runs apm update --yes and returns reloadSkills: true so the running session picks up the redeployed content. It exits silently when there is no apm.lock.yaml in the working directory, which makes it inert for any host that installed this plugin natively rather than through apm. Rationale, measurements, and the failure modes are in ADR-0019.

GitHub Copilot CLI

Copilot reads a differently-shaped hooks.json: version: 1 is required, each entry is type: "command" with separate bash and powershell scripts, and the lifecycle points are lowercase and differently named (sessionStart, sessionEnd, userPromptSubmitted, preToolUse, postToolUse, errorOccurred, agentStop). See docs/research/docs/github-copilot-plugins/configuration.md.

There is no separate Copilot hooks file at this plugin root, and — as things stand — Copilot resolves to no hooks file at all. Two corrections to an earlier revision of this document, which got both halves of this wrong:

The deleted root hooks.json was not a stale sync artifact. plugins/kyberforge/hooks.json was added in 2287ddc (2026-06-20), the commit that created the plugin, well before scripts/sync-plugin-content.sh existed; plugins/lint/hooks.json arrived the same way in f326df4. Main's Copilot manifest plugins/kyberforge/plugin.json declared "hooks": "hooks.json", and plugins/lint/plugin.json did the same — these were deliberately pointed-at Copilot hooks files, not leftovers. The sync (38f1ba4) later took ownership of that path, and ADR-0017's 2026-08-14 amendment moved the generated file to hooks/hooks.json because that, not the plugin root, is the path Claude Code convention-scans.

Only Claude Code resolves to hooks/hooks.json. Claude Code finds it by auto-discovery. Copilot does not: docs/research/docs/github-copilot-plugins/configuration.md:47 types hooks as a plugin.json field of type "string or object" with no default, so there is no convention path to scan, and jq 'has("hooks")' returns false for all six .github/plugin/plugin.json files that apm pack emits. With the pointer gone and no auto-discovery to fall back on, the Copilot ecosystem sees zero hooks.

The effect looks like the twin of the mcpServers gap that ADR-0017's 2026-08-13 amendment re-injects for: same "string or object" type, same absence of a default, same outcome of a Copilot manifest with no pointer. The mechanism differs, and ADR-0017 is explicit about it — mcpServers is actively stripped by build_plugin_manifest, whereas hooks "was never in build_plugin_manifest's strip list at all"; it is simply never emitted, because apm.yml has no key that produces one. So this is an absence apm never fills, not a removal to reverse.

Why no hooks pointer is injected

Decided (2026-08-14, PR #95): the gap stays documented rather than patched. sync-plugin-content.sh does not re-inject a hooks pointer into .github/plugin/plugin.json, and a test pins that absence. Full reasoning is in ADR-0017's "no hooks pointer" amendment; the short version, because the one-line fix looks obvious and someone will propose it again:

The mcpServers re-injection is safe because .mcp.json is one format both ecosystems read, so the pointer is a true statement about the file whatever it contains. Hooks have no shared format. Compare the two structures above: Claude Code wants PreToolUse with matcher objects; Copilot requires version: 1, lowercase event names, and per-shell bash/powershell keys. And apm merges .apm/hooks/*.json into exactly one hooks.json with no per-target shaping — the same file Claude Code convention-scans. One file, two incompatible readers.

So a pointer would tell Copilot that a Claude-shaped file is Copilot-shaped: an incomplete manifest traded for a wrong one. It is not inert even today — {"hooks": {}} has no version: 1, so the pointer would name a file invalid against the very schema it is pointed at from. And it does not become correct later: whoever writes the first real hook writes it in one shape, and it is the Claude shape in practice, since Claude Code auto-discovers the same file and is what hooks here are authored against.

What this costs you: a hook authored under .apm/hooks/ reaches Claude Code and not Copilot. That is a real limitation, and it is the accepted one until apm emits a per-target hooks file or the two schemas converge. If you need a Copilot hook today, raise it — it needs an upstream change or a second authoring path, not a pointer.

Do not author any file under plugins/kyberforge/.apm/ as a symlink. apm's bundle exporter filters symlinks out of the bundle entirely and says nothing, so the file never reaches the mirror. Since sync-plugin-content.sh builds both sides of its drift comparison from that same bundle, the loss used to be invisible to --check as well. check_apm_symlinks() now reads the .apm/ source tree directly and fails the sync with the offending path — replace the symlink with a regular file.

It stays quiet about one place: .apm/<category>/<name>/tests/, the dev-fixture directory the mirror excludes anyway (a symlink there loses nothing, because nothing under it is mirrored). A tests/ deeper than that — assets/templates/tests/, a scaffolding asset the mirror does carry — is reported like anywhere else. See ADR-0017's symlink amendment.