diff --git a/.gitignore b/.gitignore index 337210e..328bc7f 100644 --- a/.gitignore +++ b/.gitignore @@ -33,3 +33,10 @@ apm_modules/ # deployed copy would add a third mirror of the same skills to drift against. .claude/skills/ .claude/agents/ + +# APM hook deployment output — `apm install` copies each package's referenced +# hook scripts here and tracks its own settings.json entries in the sidecar. +# Regenerated on every install; the authoring source is +# plugins//.apm/hooks/ (ADR-0019). +.claude/hooks/ +.claude/apm-hooks.json diff --git a/AGENTS.md b/AGENTS.md index a817514..f0ebe20 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -37,8 +37,9 @@ Fall back to raw shell only when no skill covers it. ## Setup and testing -- Run `apm install` to deploy this repo's own skills and agents into `.claude/skills/` and `.claude/agents/`. Both are gitignored install output, not authoring source — `plugins//.apm/` remains the only place to edit. The six dependencies in root `apm.yml` resolve from the holocron **remote**, unpinned against the default branch, so a `.apm/` edit is not visible to the running session until it is pushed and `apm install` re-runs. Needs the network, and needs `apm_modules/` (which it materializes) left gitignored. `apm install` also configures the `obsidian` MCP server into the repo's `.mcp.json`, carried over from `plugins/bin/.mcp.json`. -- Do not add repo-owned keys to `.claude/settings.json`. apm treats that file as its own deployed artifact: `apm audit --ci` replays the install into a scratch tree and diffs, so anything apm would not have written there — an `enabledPlugins` block, a real `hooks` entry — is permanent drift that fails the `apm-audit-ci` pre-push hook. The file's committed content is exactly `{"hooks": {}}`. Machine-specific settings go in the gitignored `.claude/settings.local.json`, which apm does not deploy and the replay does not compare; shared enforcement belongs in `.pre-commit-config.yaml`. +- Run `apm install` to deploy this repo's own skills and agents into `.claude/skills/` and `.claude/agents/`. Both are gitignored install output, not authoring source — `plugins//.apm/` remains the only place to edit. The six dependencies in root `apm.yml` resolve from the holocron **remote**, unpinned against the default branch, so a `.apm/` edit is not visible to the running session until it is pushed and `apm update` re-runs (`apm install` deploys from `apm.lock.yaml` and does not re-resolve refs). Needs the network, and needs `apm_modules/` (which it materializes) left gitignored. `apm install` also configures the `obsidian` MCP server into the repo's `.mcp.json`, carried over from `plugins/bin/.mcp.json`. +- Do not add repo-owned keys to `.claude/settings.json`. apm treats that file as its own deployed artifact: `apm audit --ci` replays the install into a scratch tree and diffs, so anything apm would not have written there — an `enabledPlugins` block, a real `hooks` entry — is permanent drift that fails the `apm-audit-ci` pre-push hook. Its committed content is whatever apm last wrote — `{"hooks": {}}` until kyberforge's `SessionStart` hook lands there, after which the merged hook entry is apm's output and belongs in the commit (ADR-0019). What does not change is that nothing repo-authored goes in the file. A hook you want in this repo is authored in `plugins//.apm/hooks/` and deployed by apm, never hand-written here. Machine-specific settings go in the gitignored `.claude/settings.local.json`, which apm does not deploy and the replay does not compare; shared enforcement belongs in `.pre-commit-config.yaml`. +- Keeping the install current is automatic but not free. Because the six dependencies are unpinned, deployed skills go stale whenever anyone merges. kyberforge ships a `SessionStart` hook that runs `apm outdated` at startup (~0.7s) and, when something is behind, runs `apm update --yes` and asks the host to re-scan skills (~10.4s). That rewrites `apm.lock.yaml`, so an unexplained modification to it after opening a session is expected, not a bug — commit or discard it deliberately. Note `apm install` alone will **not** pick up remote changes; it deploys from the lock. `apm update` is the command that re-resolves refs. - Install git hooks via `pc-run`, wiring all three stages — this repo's `.pre-commit-config.yaml` has no `default_install_hook_types`, so a plain install silently skips `commit-msg` (Conventional Commits) and `pre-push` (the 13-hook gate described below). - Install the `apm` CLI — four pre-push hooks shell out to it: `apm-marketplace-check`, `apm-audit-ci`, `apm-pack-check-clean`, and `check-plugin-content-sync` (via `scripts/sync-plugin-content.sh`, which wraps `apm pack`). `apm-marketplace-check` and `apm-pack-check-clean` are bare `apm …` hook entries and `apm-audit-ci` is a `bash -c` loop calling `apm` once per package, so without it the push dies with an unhelpful "command not found". Use `apm-install`, or `curl -sSL https://aka.ms/apm-unix | sh`; verify with `apm --version`. - Install `jq` — required by `scripts/check-manifests.sh` and `scripts/sync-plugin-content.sh`, both pre-push. These at least fail loudly (`Error: jq is required but not installed`). diff --git a/apm.yml b/apm.yml index 27b4ae7..bd3e5b7 100644 --- a/apm.yml +++ b/apm.yml @@ -30,6 +30,22 @@ dependencies: path: plugins/lint mcp: [] +# Turns apm's executable-trust gate ON. Without this block the gate is disabled +# and every hook, bin and MCP primitive a dependency ships deploys silently — +# verified: `apm approve --list` reports "Executable-trust gate disabled -- all +# executables deploy" until an `executables:` block exists. +# +# kyberforge ships the SessionStart hook that keeps this install level with the +# remote (ADR-0019). The key is version-pinned by apm's own design, so a +# kyberforge version bump makes this entry stop matching and the hook stops +# deploying until the version here is bumped too. If skills silently go stale +# after a kyberforge release, check this first. +executables: + allow: + kyberforge#1.4.1: + hooks: true + bin: true + marketplace: # apm's Claude marketplace mapper only emits description:/version: into the # compiled marketplace.json when set explicitly here (an override) — the diff --git a/docs/adr/0018-repo-consumes-its-own-plugins-through-apm.md b/docs/adr/0018-repo-consumes-its-own-plugins-through-apm.md index b3ccfa5..f113990 100644 --- a/docs/adr/0018-repo-consumes-its-own-plugins-through-apm.md +++ b/docs/adr/0018-repo-consumes-its-own-plugins-through-apm.md @@ -61,7 +61,9 @@ was stale the moment the switch landed; `AGENTS.md` and `CONTEXT.md` are updated form still resolves for anyone installing holocron natively, so skill bodies written for both audiences should name the bare skill. -**apm owns `.claude/settings.json`.** `apm audit --ci` replays the install into a scratch tree and +**apm owns `.claude/settings.json`.** (ADR-0019 supersedes the "exactly `{"hooks": {}}`" claim +below — once a package ships a hook, apm merges it into that file and the merged entry is apm's own +output. The rule that nothing repo-authored goes in the file is unchanged.) `apm audit --ci` replays the install into a scratch tree and diffs it against the worktree. apm's hook integrator writes that file, so the replay expects exactly what apm would have written — `{"hooks": {}}` — and any repo-owned key in it is permanent drift that fails the `apm-audit-ci` pre-push hook. Verified both directions: with the pre-existing @@ -93,9 +95,16 @@ apm's "contributed no entries to claude settings; skipped" warning on `kyberforg accurate and harmless. **A `.apm/` edit now needs a round trip.** The dependency resolves from the remote, so an edit is -invisible to the running session until it is pushed and `apm install` re-runs. Under the native -install with `autoUpdate` the shape was the same; it is more noticeable now because `apm install` -is a manual step where marketplace auto-update was not. +invisible to the running session until it is pushed and the install is refreshed. Under the native +install with `autoUpdate` the shape was the same; it was more noticeable here at first because the +refresh is a manual step where marketplace auto-update was not — ADR-0019 automates it at +`SessionStart`. + +**Correction (2026-08-14): the refresh command is `apm update`, not `apm install`.** An earlier +revision of this paragraph named `apm install`, which is wrong: `apm install` deploys from the +pinned `resolved_commit` in `apm.lock.yaml` and does not re-resolve refs (`apm install --force` +documents this explicitly — "does NOT refresh refs; use 'apm update' for that"). Running it after a +merge redeploys the same content and reports success. **User scope is untouched, deliberately.** `bin@holocron`, `gitea@holocron`, and a stale `hello-world@holocron` remain natively installed at user scope, and every project other than this diff --git a/docs/adr/0019-session-start-hook-keeps-the-apm-install-current.md b/docs/adr/0019-session-start-hook-keeps-the-apm-install-current.md new file mode 100644 index 0000000..f96b17b --- /dev/null +++ b/docs/adr/0019-session-start-hook-keeps-the-apm-install-current.md @@ -0,0 +1,119 @@ +# 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//`. 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. diff --git a/docs/spec/architecture.md b/docs/spec/architecture.md index fecc69f..72f299c 100644 --- a/docs/spec/architecture.md +++ b/docs/spec/architecture.md @@ -63,4 +63,4 @@ This repo also has a `CLAUDE.md` at its root — the Claude Code entry point for ## Architectural decisions -Key hard-to-reverse decisions are recorded as ADRs in `docs/adr/`. There is no index file — the directory holds 17 numbered ADRs whose filenames state their decision, so `ls docs/adr/` is the index. Read a superseding ADR before the one it supersedes: ADR-0015 (apm as the authoring source of truth) supersedes ADR-0001 and moots ADR-0006, and ADR-0017 corrects ADR-0015's host-discovery gap. Entry points for the structure described on this page: ADR-0002 (two-tier CLAUDE.md), ADR-0003 (AGENTS.md as the provider-agnostic entry point), ADR-0015 and ADR-0017 (the two compilers behind the plugin roots). +Key hard-to-reverse decisions are recorded as ADRs in `docs/adr/`. There is no index file — the directory holds 19 numbered ADRs whose filenames state their decision, so `ls docs/adr/` is the index. Read a superseding ADR before the one it supersedes: ADR-0015 (apm as the authoring source of truth) supersedes ADR-0001 and moots ADR-0006, ADR-0017 corrects ADR-0015's host-discovery gap, and ADR-0019 supersedes one claim in ADR-0018 (that `.claude/settings.json`'s committed content is exactly `{"hooks": {}}`) while keeping the rule behind it. Entry points for the structure described on this page: ADR-0002 (two-tier CLAUDE.md), ADR-0003 (AGENTS.md as the provider-agnostic entry point), ADR-0015 and ADR-0017 (the two compilers behind the plugin roots). diff --git a/plugins/kyberforge/.apm/hooks/check-apm-current.sh b/plugins/kyberforge/.apm/hooks/check-apm-current.sh new file mode 100755 index 0000000..d1c57d5 --- /dev/null +++ b/plugins/kyberforge/.apm/hooks/check-apm-current.sh @@ -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 diff --git a/plugins/kyberforge/.apm/hooks/hooks.json b/plugins/kyberforge/.apm/hooks/hooks.json index deffac9..e33213b 100644 --- a/plugins/kyberforge/.apm/hooks/hooks.json +++ b/plugins/kyberforge/.apm/hooks/hooks.json @@ -1,3 +1,16 @@ { - "hooks": {} + "hooks": { + "SessionStart": [ + { + "hooks": [ + { + "command": "${CLAUDE_PLUGIN_ROOT}/.apm/hooks/check-apm-current.sh", + "timeout": 320, + "type": "command" + } + ], + "matcher": "startup" + } + ] + } } diff --git a/plugins/kyberforge/docs/hooks.md b/plugins/kyberforge/docs/hooks.md index 65fc4ea..6b31c46 100644 --- a/plugins/kyberforge/docs/hooks.md +++ b/plugins/kyberforge/docs/hooks.md @@ -34,16 +34,58 @@ mirrored file; the `check-plugin-content-sync` pre-push hook reports it as drift } ``` -Events (**partial list**): `PreToolUse`, `PostToolUse`, `Notification`, `Stop`. Claude Code's plugin -hook set is larger — `SessionStart`, `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 four 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. +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. -Use `${CLAUDE_PLUGIN_ROOT}` to reference scripts inside this plugin — the plugin runs from a cache -path after install, not its original repo location. +## 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:** + +```json +"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//` (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 diff --git a/plugins/kyberforge/hooks/hooks.json b/plugins/kyberforge/hooks/hooks.json index deffac9..e33213b 100644 --- a/plugins/kyberforge/hooks/hooks.json +++ b/plugins/kyberforge/hooks/hooks.json @@ -1,3 +1,16 @@ { - "hooks": {} + "hooks": { + "SessionStart": [ + { + "hooks": [ + { + "command": "${CLAUDE_PLUGIN_ROOT}/.apm/hooks/check-apm-current.sh", + "timeout": 320, + "type": "command" + } + ], + "matcher": "startup" + } + ] + } } diff --git a/scripts/git-hooks/post-push b/scripts/git-hooks/post-push deleted file mode 100755 index 63c149e..0000000 --- a/scripts/git-hooks/post-push +++ /dev/null @@ -1,17 +0,0 @@ -#!/usr/bin/env bash -# Refresh the kyberforge plugin cache after every push -set -euo pipefail - -echo "→ Refreshing holocron marketplace clone..." -if git -C ~/.claude/plugins/marketplaces/holocron pull --quiet; then - echo "→ Updating kyberforge plugin cache..." - if claude plugin update kyberforge; then - echo "✓ kyberforge cache updated" - else - echo "⚠ claude plugin update kyberforge failed — run it manually" >&2 - fi -else - echo "⚠ Failed to pull holocron marketplace clone — run 'git -C ~/.claude/plugins/marketplaces/holocron pull' manually" >&2 -fi - -exit 0 diff --git a/tests/test-apm-current-hook.sh b/tests/test-apm-current-hook.sh new file mode 100755 index 0000000..3f66535 --- /dev/null +++ b/tests/test-apm-current-hook.sh @@ -0,0 +1,159 @@ +#!/usr/bin/env bash +# Tests for plugins/kyberforge/.apm/hooks/check-apm-current.sh — the SessionStart +# hook that keeps an apm-consumed install level with its remote. +# +# `apm` is mocked throughout: the hook's contract is "read `apm outdated`, decide, +# emit SessionStart JSON", and that is testable without a network or a real +# install. +set -euo pipefail + +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +HOOK="$REPO_ROOT/plugins/kyberforge/.apm/hooks/check-apm-current.sh" +HOOKS_JSON="$REPO_ROOT/plugins/kyberforge/.apm/hooks/hooks.json" +PASS=0 +FAIL=0 + +pass() { echo " PASS: $1"; PASS=$((PASS + 1)); } +fail() { echo " FAIL: $1"; FAIL=$((FAIL + 1)); } + +command -v python3 > /dev/null 2>&1 || { echo "python3 required"; exit 77; } + +FAKE_BIN="$(mktemp -d)" +WORK="$(mktemp -d)" +trap 'rm -rf "$FAKE_BIN" "$WORK"' EXIT + +# Mock `apm`. $1 chooses what `apm outdated` reports; $2 the exit code of +# `apm update`. A sentinel file records whether update was actually invoked. +make_apm() { + local outdated_line="$1" update_exit="$2" + cat > "$FAKE_BIN/apm" << EOF +#!/usr/bin/env bash +case "\$1" in + outdated) echo "$outdated_line"; exit 0 ;; + update) touch "$WORK/update-was-called"; exit $update_exit ;; +esac +exit 0 +EOF + chmod +x "$FAKE_BIN/apm" +} + +run_hook() { (cd "$WORK" && PATH="$FAKE_BIN:$PATH" bash "$HOOK" 2>/dev/null); } + +json_field() { python3 -c 'import json,sys; print(json.load(sys.stdin)["hookSpecificOutput"][sys.argv[1]])' "$1"; } + +# --------------------------------------------------------------------------- +echo "--- inert without an apm-consumed install ---" +# --------------------------------------------------------------------------- + +make_apm "[!] 6 outdated dependencies found" 0 +rm -f "$WORK/apm.lock.yaml" "$WORK/update-was-called" +out="$(run_hook)"; rc=$? +[[ $rc -eq 0 ]] && pass "exits 0 with no apm.lock.yaml" || fail "should exit 0 with no apm.lock.yaml" +[[ -z "$out" ]] && pass "emits nothing with no apm.lock.yaml" || fail "should stay silent with no apm.lock.yaml" +[[ ! -f "$WORK/update-was-called" ]] && pass "does not run apm update with no apm.lock.yaml" \ + || fail "must not touch a project that does not use apm" + +# A native (non-apm) install of this plugin hits exactly this path, so it is the +# guard that keeps the hook from acting on someone else's repo. +touch "$WORK/apm.lock.yaml" + +# --------------------------------------------------------------------------- +echo "" +echo "--- inert when apm is absent ---" +# --------------------------------------------------------------------------- + +rm -f "$WORK/update-was-called" +out="$( (cd "$WORK" && PATH="$(dirname "$(command -v bash)")" bash "$HOOK" 2>/dev/null) )"; rc=$? +[[ $rc -eq 0 ]] && pass "exits 0 when apm is not on PATH" || fail "should exit 0 when apm is missing" +[[ -z "$out" ]] && pass "emits nothing when apm is not on PATH" || fail "should stay silent when apm is missing" + +# --------------------------------------------------------------------------- +echo "" +echo "--- install already current ---" +# --------------------------------------------------------------------------- + +make_apm "[*] All dependencies are up-to-date" 0 +rm -f "$WORK/update-was-called" +out="$(run_hook)"; rc=$? +[[ $rc -eq 0 ]] && pass "exits 0 when current" || fail "should exit 0 when current" +[[ -z "$out" ]] && pass "emits nothing when current" || fail "should stay silent when current" +[[ ! -f "$WORK/update-was-called" ]] && pass "does not run apm update when current" \ + || fail "must not update when nothing is stale" + +# --------------------------------------------------------------------------- +echo "" +echo "--- stale, refresh succeeds ---" +# --------------------------------------------------------------------------- + +make_apm "[!] 6 outdated dependencies found" 0 +rm -f "$WORK/update-was-called" +out="$(run_hook)"; rc=$? +[[ $rc -eq 0 ]] && pass "exits 0 when stale" || fail "should exit 0 when stale" +[[ -f "$WORK/update-was-called" ]] && pass "runs apm update when stale" || fail "should run apm update when stale" +if echo "$out" | python3 -m json.tool > /dev/null 2>&1; then + pass "emits valid JSON" + [[ "$(echo "$out" | json_field hookEventName)" == "SessionStart" ]] \ + && pass "declares hookEventName SessionStart" || fail "wrong hookEventName" + [[ "$(echo "$out" | json_field reloadSkills)" == "True" ]] \ + && pass "asks the host to reload skills after a successful refresh" || fail "reloadSkills should be true" + echo "$out" | json_field additionalContext | grep -q "6 package" \ + && pass "reports the stale package count" || fail "should report the count" + # The lockfile rewrite is the surprising part of auto-updating; the notice has + # to say so or a dirty worktree looks like something else went wrong. + echo "$out" | json_field additionalContext | grep -q "apm.lock.yaml" \ + && pass "warns that apm.lock.yaml was rewritten" || fail "should warn about the lockfile rewrite" +else + fail "emits valid JSON" +fi + +# --------------------------------------------------------------------------- +echo "" +echo "--- stale, refresh fails ---" +# --------------------------------------------------------------------------- + +make_apm "[!] 3 outdated dependencies found" 1 +out="$(run_hook)"; rc=$? +[[ $rc -eq 0 ]] && pass "exits 0 when the refresh fails" || fail "must never fail the session start" +if echo "$out" | python3 -m json.tool > /dev/null 2>&1; then + pass "emits valid JSON on failure" + [[ "$(echo "$out" | json_field reloadSkills)" == "False" ]] \ + && pass "does not ask for a skill reload when nothing was deployed" || fail "reloadSkills should be false" + echo "$out" | json_field additionalContext | grep -q "apm update" \ + && pass "tells the reader how to refresh by hand" || fail "should name the manual command" +else + fail "emits valid JSON on failure" +fi + +# --------------------------------------------------------------------------- +echo "" +echo "--- unparseable count degrades instead of breaking the JSON ---" +# --------------------------------------------------------------------------- + +make_apm "[!] lots of outdated dependencies found" 0 +out="$(run_hook)" +echo "$out" | python3 -m json.tool > /dev/null 2>&1 \ + && pass "still emits valid JSON when the count cannot be parsed" || fail "JSON broke on an unparseable count" + +# --------------------------------------------------------------------------- +echo "" +echo "--- hooks.json wiring ---" +# --------------------------------------------------------------------------- + +# apm resolves script paths relative to the package root, and `apm pack` keeps +# only *.json from .apm/hooks/ — so a ${CLAUDE_PLUGIN_ROOT}/hooks/... reference +# points at a directory the script never reaches. It must be .apm/-relative. +referenced="$(python3 -c 'import json,sys; d=json.load(open(sys.argv[1])); print(d["hooks"]["SessionStart"][0]["hooks"][0]["command"])' "$HOOKS_JSON")" +[[ "$referenced" == '${CLAUDE_PLUGIN_ROOT}/.apm/hooks/check-apm-current.sh' ]] \ + && pass "hooks.json references the script at its .apm/ path" \ + || fail "hooks.json references '$referenced' — must be \${CLAUDE_PLUGIN_ROOT}/.apm/hooks/check-apm-current.sh" + +[[ -x "$HOOK" ]] && pass "hook script is executable" || fail "hook script must be executable" + +matcher="$(python3 -c 'import json,sys; d=json.load(open(sys.argv[1])); print(d["hooks"]["SessionStart"][0]["matcher"])' "$HOOKS_JSON")" +[[ "$matcher" == "startup" ]] && pass "fires on startup only" \ + || fail "matcher is '$matcher' — resume/clear/compact would re-run this every compaction" + +# --------------------------------------------------------------------------- +echo "" +echo "Results: $PASS passed, $FAIL failed" +[[ $FAIL -eq 0 ]] diff --git a/tests/test-git-hooks-install.sh b/tests/test-git-hooks-install.sh index 8a36350..15be37e 100755 --- a/tests/test-git-hooks-install.sh +++ b/tests/test-git-hooks-install.sh @@ -40,11 +40,25 @@ DEPLOY_EXECUTABLES=() DEPLOY_DIRS=() EOF -# Copy the real install.sh and git-hooks into the temp repo +# Copy the real install.sh into the temp repo. cp "$REPO_ROOT/scripts/install.sh" "$TEMP_REPO/scripts/install.sh" -cp -r "$REPO_ROOT/scripts/git-hooks" "$TEMP_REPO/scripts/git-hooks" HOOKS_SRC="$TEMP_REPO/scripts/git-hooks" +mkdir -p "$HOOKS_SRC" + +# Copy whatever real hooks exist, then add a synthetic fixture. The repo +# currently ships none — the only entry was `post-push`, removed once it was +# found that git has no such client-side hook, so it had never fired (see +# ADR-0019). install.sh's copy block is generic and stays worth testing, so the +# fixture keeps that coverage alive independently of whether any real hook +# happens to exist. Real hooks are still picked up by the loops below. +if [[ -d "$REPO_ROOT/scripts/git-hooks" ]]; then + find "$REPO_ROOT/scripts/git-hooks" -maxdepth 1 -type f -exec cp {} "$HOOKS_SRC/" \; +fi + +FIXTURE_HOOK="fixture-hook" +printf '#!/usr/bin/env bash\nexit 0\n' > "$HOOKS_SRC/$FIXTURE_HOOK" +chmod +x "$HOOKS_SRC/$FIXTURE_HOOK" run_install() { HOME="$TEMP_HOME" bash "$TEMP_REPO/scripts/install.sh" > /dev/null 2>&1 @@ -108,7 +122,7 @@ OTHER_REPO="$(mktemp -d)" git -C "$OTHER_REPO" init -q if HOME="$TEMP_HOME" GIT_DIR="$OTHER_REPO/.git" GIT_WORK_TREE="$OTHER_REPO" \ bash "$TEMP_REPO/scripts/install.sh" > /dev/null 2>&1; then - if [[ -f "$TEMP_REPO/.git/hooks/post-push" ]]; then + if [[ -f "$TEMP_REPO/.git/hooks/$FIXTURE_HOOK" ]]; then pass "resolves \$TEMP_REPO/.git/hooks/ even with inherited GIT_DIR" else fail "installed into inherited GIT_DIR instead of \$TEMP_REPO" diff --git a/tests/test-post-push.sh b/tests/test-post-push.sh deleted file mode 100755 index a6fa4eb..0000000 --- a/tests/test-post-push.sh +++ /dev/null @@ -1,108 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" -PASS=0 -FAIL=0 - -pass() { echo " PASS: $1"; PASS=$((PASS + 1)); } -fail() { echo " FAIL: $1"; FAIL=$((FAIL + 1)); } - -HOOK="$REPO_ROOT/scripts/git-hooks/post-push" -FAKE_BIN="$(mktemp -d)" -FAKE_HOME="$(mktemp -d)" -trap 'rm -rf "$FAKE_BIN" "$FAKE_HOME"' EXIT - -# Helper: write a fake git stub that exits with the given code -make_git() { - local exit_code="$1" - printf '#!/usr/bin/env bash\nexit %s\n' "$exit_code" > "$FAKE_BIN/git" - chmod +x "$FAKE_BIN/git" -} - -# Helper: write a fake claude stub; optionally touches a sentinel file on invocation -make_claude() { - local exit_code="$1" - local log="${2:-}" - if [[ -n "$log" ]]; then - printf '#!/usr/bin/env bash\ntouch "%s"\nexit %s\n' "$log" "$exit_code" > "$FAKE_BIN/claude" - else - printf '#!/usr/bin/env bash\nexit %s\n' "$exit_code" > "$FAKE_BIN/claude" - fi - chmod +x "$FAKE_BIN/claude" -} - -# Run the hook with mocked PATH and HOME; suppress all output -run_hook() { - PATH="$FAKE_BIN:$PATH" HOME="$FAKE_HOME" bash "$HOOK" > /dev/null 2>&1 -} - -# Run the hook and capture combined stdout+stderr -capture_hook() { - PATH="$FAKE_BIN:$PATH" HOME="$FAKE_HOME" bash "$HOOK" 2>&1 || true -} - -# --------------------------------------------------------------------------- -echo "--- post-push: always exits 0 ---" -# --------------------------------------------------------------------------- - -make_git 1; make_claude 0 -if run_hook; then - pass "exits 0 when git pull fails" -else - fail "should exit 0 when git pull fails" -fi - -make_git 0; make_claude 1 -if run_hook; then - pass "exits 0 when claude plugin update fails" -else - fail "should exit 0 when claude plugin update fails" -fi - -# --------------------------------------------------------------------------- -echo "" -echo "--- post-push: success output ---" -# --------------------------------------------------------------------------- - -make_git 0; make_claude 0 -output=$(capture_hook) -echo "$output" | grep -q "kyberforge cache updated" \ - && pass "prints success message when both git pull and claude succeed" \ - || fail "should print success message when both commands succeed" - -# --------------------------------------------------------------------------- -echo "" -echo "--- post-push: warnings on failure ---" -# --------------------------------------------------------------------------- - -make_git 1; make_claude 0 -output=$(capture_hook) -echo "$output" | grep -qi "failed to pull" \ - && pass "warns when git pull fails" \ - || fail "should warn when git pull fails" - -make_git 0; make_claude 1 -output=$(capture_hook) -echo "$output" | grep -qi "failed" \ - && pass "warns when claude plugin update fails" \ - || fail "should warn when claude plugin update fails" - -# --------------------------------------------------------------------------- -echo "" -echo "--- post-push: claude not called when git pull fails ---" -# --------------------------------------------------------------------------- - -CLAUDE_LOG="$FAKE_HOME/claude-was-called" -make_git 1; make_claude 0 "$CLAUDE_LOG" -run_hook -if [[ ! -f "$CLAUDE_LOG" ]]; then - pass "claude not called when git pull fails" -else - fail "claude should not be called when git pull fails" -fi - -# --------------------------------------------------------------------------- -echo "" -echo "Results: $PASS passed, $FAIL failed" -[[ $FAIL -eq 0 ]]