Files
holocron/plugins/kyberforge/.apm/hooks/check-apm-current.sh
Defame1297 ae178a95a2 fix(kyberforge): detect a single stale package at SessionStart
apm prints "1 outdated dependency found" in the singular when exactly one
package is behind (apm_cli/commands/outdated.py). check-apm-current.sh
matched only "outdated dependencies found", so one stale package was
invisible: the hook exited 0 silently and no refresh ran. With six
packages merging independently, one-behind is the ordinary case, so the
freshness mechanism failed most often in the situation it exists for.

Three further defects in the same hook:

- The host timeout was below the script's own budget. hooks.json declared
  320s while the script allows `timeout 60` plus `timeout 300` = 360s, so
  a slow remote let the host kill the hook mid-update and leave
  .claude/skills/ half-deployed with nothing emitted. Now 380. A test
  asserts the invariant rather than the literal: it sums every `timeout N`
  parsed out of the script and requires hooks.json to exceed it, so
  changing either side alone fails.

- The lockfile guard was cwd-relative, so a session opened in a
  subdirectory no-opped silently and ran both apm calls against the wrong
  directory. Now anchored on CLAUDE_PROJECT_DIR, falling back to the cwd
  so the hook stays inert under a host that does not set it.

- Every assertion mocked apm, so the suite was green over code that could
  not detect its own most common trigger. That blind spot is what hid the
  singular/plural bug, and it is the same shape as the deleted post-push
  tests. The suite now stages a genuinely outdated dependency against a
  local git remote — offline, via url.<path>.insteadOf, so the
  pass-under-unshare property survives — runs the real `apm outdated`, and
  replays its output through the real hook. Reverting the grep to
  plural-only fails it.

23 -> 35 assertions. Each fix mutation-tested individually. kyberforge
stays at 1.5.0: it is untagged, so this changes what 1.5.0 ships rather
than superseding it, and executables.allow needs no edit.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01X7GvKuJfy2WrdBmUttV4DT
2026-08-14 18:31:30 +00:00

61 lines
3.2 KiB
Bash
Executable File

#!/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
# Anchor on the project root, not the session's cwd. Claude Code exports
# CLAUDE_PROJECT_DIR for SessionStart hooks; a session opened in a subdirectory
# would otherwise miss the lockfile, no-op silently, and — worse — run the apm
# calls below against that wrong directory. Fall back to the cwd when the
# variable is absent, which keeps the hook inert-but-harmless under a host that
# does not set it.
project_dir="${CLAUDE_PROJECT_DIR:-$PWD}"
# No lockfile means nothing was installed through apm here — e.g. a host that
# installed this plugin natively. Say nothing and cost nothing.
[[ -f "$project_dir/apm.lock.yaml" ]] || exit 0
command -v apm > /dev/null 2>&1 || exit 0
# Every apm call below must see the same directory the guard just checked —
# `apm outdated` and `apm update` both resolve the lockfile from the cwd.
cd "$project_dir" || 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.
#
# There is no --json/machine-readable flag on `apm outdated` (verified against
# apm 0.28.0), so the phrase match is forced rather than chosen. Note the
# singular: apm prints "1 outdated dependency found" when exactly one package is
# behind, so matching only "dependencies" would silently miss a one-package
# drift. tests/test-apm-current-hook.sh pins both spellings against the real apm.
outdated_output="$(timeout 60 apm outdated 2>&1)" || exit 0
grep -qE 'outdated dependenc(y|ies) found' <<< "$outdated_output" || exit 0
stale_count="$(grep -oE '[0-9]+ outdated dependenc(y|ies) 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