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
This commit is contained in:
@@ -13,18 +13,36 @@
|
||||
# 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 apm.lock.yaml ]] || exit 0
|
||||
[[ -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 -q "outdated dependencies found" <<< "$outdated_output" || exit 0
|
||||
grep -qE 'outdated dependenc(y|ies) found' <<< "$outdated_output" || exit 0
|
||||
|
||||
stale_count="$(grep -oE '[0-9]+ outdated dependencies found' <<< "$outdated_output" | grep -oE '^[0-9]+' || true)"
|
||||
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
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
"hooks": [
|
||||
{
|
||||
"command": "${CLAUDE_PLUGIN_ROOT}/.apm/hooks/check-apm-current.sh",
|
||||
"timeout": 320,
|
||||
"timeout": 380,
|
||||
"type": "command"
|
||||
}
|
||||
],
|
||||
|
||||
@@ -83,9 +83,33 @@ Note that apm's **executable-trust gate is off** unless the consuming project's
|
||||
|
||||
`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.
|
||||
session picks up the redeployed content. Rationale, measurements, and the failure modes are in
|
||||
ADR-0019.
|
||||
|
||||
**Where it looks for the lockfile.** The hook resolves a project directory as `${CLAUDE_PROJECT_DIR}`
|
||||
when the host exports it (Claude Code does, for SessionStart hooks) and the current directory
|
||||
otherwise, then exits silently unless that directory holds an `apm.lock.yaml` — which is what makes
|
||||
it inert for any host that installed this plugin natively rather than through apm. Both `apm`
|
||||
invocations run against the same resolved directory. The earlier spelling checked a bare
|
||||
`apm.lock.yaml` against the session's cwd, so a session opened in a subdirectory of an
|
||||
apm-consuming repo no-opped silently. Keep the cwd fallback: a host that sets no
|
||||
`CLAUDE_PROJECT_DIR` must still get inert-but-harmless behaviour, not an unset-variable error.
|
||||
|
||||
**The `timeout` in `hooks.json` must exceed the script's own budget.** The script spends at most
|
||||
`timeout 60 apm outdated` plus `timeout 300 apm update`; the hook entry declares `timeout: 380`, the
|
||||
sum plus a buffer. Set it lower and a slow remote gets the hook SIGKILLed mid-`apm update`, leaving a
|
||||
partially redeployed `.claude/skills/` and emitting no notice — precisely the silent failure the hook
|
||||
exists to prevent. `tests/test-apm-current-hook.sh` pins the relationship (host timeout > sum of the
|
||||
script's internal timeouts) rather than the literal, so raising either side alone fails the suite.
|
||||
|
||||
**Staleness is detected by matching apm's summary line, and both spellings count.** `apm outdated`
|
||||
has no `--json` or otherwise machine-readable output (verified against apm 0.28.0), so the hook
|
||||
greps its text. apm prints `1 outdated dependency found` in the singular when exactly one package is
|
||||
behind and `N outdated dependencies found` otherwise; matching only the plural silently misses a
|
||||
one-package drift. Because a mocked `apm` would keep a reworded release invisible, the test suite
|
||||
stages a genuinely outdated dependency against the **real** `apm` — a local git repo reached through
|
||||
`url.<path>.insteadOf` rewrites, so it needs no network — and replays that genuine output through the
|
||||
hook.
|
||||
|
||||
## GitHub Copilot CLI
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
"hooks": [
|
||||
{
|
||||
"command": "${CLAUDE_PLUGIN_ROOT}/.apm/hooks/check-apm-current.sh",
|
||||
"timeout": 320,
|
||||
"timeout": 380,
|
||||
"type": "command"
|
||||
}
|
||||
],
|
||||
|
||||
Reference in New Issue
Block a user