4 Commits

Author SHA1 Message Date
c9fe2e8ab2 docs: correct the namespace claim and record the hook fixes
AGENTS.md and CONTEXT.md asserted that the `<plugin>:<skill>` form "no
longer resolves here". It does: ~/.claude.json still enables core, git,
gitea, kyberforge and lint at user scope, which ADR-0018 left in place
deliberately. Both names are live at once, so a working `gitea:gitea-prs`
is the user-scope copy answering — not evidence that the apm install is
broken and not something to "fix". ADR-0018 contradicted itself on this,
claiming every namespaced reference went stale while its own "User scope
is untouched" consequence said otherwise; recorded as a dated correction
alongside the existing one. Bare names stay the documented default.

Five stale pre-push hook counts updated for the new
check-executables-allow-sync gate: 13 -> 14 repo-defined hooks, 15 -> 16
reported by pre-commit, eleven -> twelve passing offline. The gate reads
two local manifests and makes no network call, so the SKIP pair for
offline pushes stays exactly two. "Four pre-push hooks shell out to apm"
is unchanged and still correct — the new hook parses YAML directly.

ADR-0019 gains the timeout arithmetic, the singular/plural failure and
why mocking every apm call hid it, and a consequence recording that the
trust gate is keyed on version rather than content: an edit to a hook
script landing on main deploys and executes unattended on the next
session start, since the dependency is unpinned and the hook runs
`apm update --yes`. That is accepted, not overlooked, but it is why the
gate should not be read as a supply-chain control.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01X7GvKuJfy2WrdBmUttV4DT
2026-08-14 18:31:42 +00:00
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
b4f5881973 build(apm): pin the obsidian MCP server and release bin 1.1.3
plugins/bin/.mcp.json declared the obsidian server as
`npx @bitbonsai/mcpvault@latest`, so an unpinned third-party npm package
was fetched and executed at every session start. The apm-consumed install
promoted that string to committed repo-root content in .mcp.json, giving
every clone the same unpinned execution. Pinned to 0.15.0, the version
`latest` currently resolves to.

bin 1.1.2 -> 1.1.3 and marketplace 0.4.0 -> 0.4.1, following the mapping
bb9158d establishes and 3bfdf58 confirms: the marketplace takes the same
bump severity as the highest-severity package bump. kyberforge is not
bumped here, so executables.allow's `kyberforge#1.5.0` key is untouched.

The pin is not live for this working copy until this lands on the remote
and `apm update` re-resolves — apm.lock.yaml still records 1.1.2 and
`@latest`, because the six dependencies resolve from the remote rather
than from the tree beside them. Correct for a fresh clone immediately.

.gitignore gains /.claude-plugin/plugin.json: a bare `apm pack` emits a
root-package manifest there that has never been tracked on any branch.
Scoped to the file, since the sibling marketplace.json is compiled output
that is committed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01X7GvKuJfy2WrdBmUttV4DT
2026-08-14 18:31:16 +00:00
099cf5846c ci: gate the executables.allow key against kyberforge's version
apm approves a package's hooks and bin by an exact dictionary lookup on a
composed `name#version` key (apm_cli/security/executables.py,
is_package_approved). There is no wildcard and no version-less form, so
bumping plugins/kyberforge/apm.yml without editing root apm.yml's
`kyberforge#<version>` key errors nowhere: the entry stops matching, the
SessionStart hook stops deploying, and the install goes quietly stale.

ADR-0019 already named that as a live failure mode, mitigated only by a
comment in the executables block. This repo gates generated-content
drift, marketplace mirror drift and vale style drift deterministically,
and a silent-staleness failure is worse than any of them — a comment does
not survive the release that breaks it.

check-executables-allow-sync.sh parses the version out of the plugin
manifest and asserts the matching key exists in the root manifest. It
uses PyYAML where importable and falls back to a two-shape scan
otherwise, so a missing pip package cannot become the thing that blocks
every push; the test asserts both readers agree. 23 assertions.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01X7GvKuJfy2WrdBmUttV4DT
2026-08-14 18:31:06 +00:00
22 changed files with 839 additions and 40 deletions

View File

@@ -1,7 +1,7 @@
{ {
"name": "holocron", "name": "holocron",
"description": "AI development skills for Claude Code and GitHub Copilot CLI — factory, design, implement, review, and cross-cutting workflows.", "description": "AI development skills for Claude Code and GitHub Copilot CLI — factory, design, implement, review, and cross-cutting workflows.",
"version": "0.4.0", "version": "0.4.1",
"owner": { "owner": {
"name": "Defame1297", "name": "Defame1297",
"email": "defame1297@rkdr.net", "email": "defame1297@rkdr.net",
@@ -18,7 +18,7 @@
{ {
"name": "bin", "name": "bin",
"description": "A place for things to be binned", "description": "A place for things to be binned",
"version": "1.1.2", "version": "1.1.3",
"category": "Utilities", "category": "Utilities",
"source": "./plugins/bin" "source": "./plugins/bin"
}, },

View File

@@ -1,7 +1,7 @@
{ {
"name": "holocron", "name": "holocron",
"description": "AI development skills for Claude Code and GitHub Copilot CLI — factory, design, implement, review, and cross-cutting workflows.", "description": "AI development skills for Claude Code and GitHub Copilot CLI — factory, design, implement, review, and cross-cutting workflows.",
"version": "0.4.0", "version": "0.4.1",
"owner": { "owner": {
"name": "Defame1297", "name": "Defame1297",
"email": "defame1297@rkdr.net", "email": "defame1297@rkdr.net",
@@ -18,7 +18,7 @@
{ {
"name": "bin", "name": "bin",
"description": "A place for things to be binned", "description": "A place for things to be binned",
"version": "1.1.2", "version": "1.1.3",
"category": "Utilities", "category": "Utilities",
"source": "./plugins/bin" "source": "./plugins/bin"
}, },

8
.gitignore vendored
View File

@@ -44,3 +44,11 @@ apm_modules/
# `apm pack` bundle output. The pre-push gate runs pack with --dry-run, so this # `apm pack` bundle output. The pre-push gate runs pack with --dry-run, so this
# only appears after a bare `apm pack` during a release; it is not repo content. # only appears after a bare `apm pack` during a release; it is not repo content.
build/ build/
# `apm pack`'s manifest for the *root* package. Emitted beside the marketplace
# manifest by a bare `apm pack`, and never tracked on any branch — the repo's
# own paths hide it, since sync-plugin-content.sh redirects `apm pack -o` to a
# scratch tree and the apm-pack-check-clean pre-push hook runs --dry-run. Scoped
# to the file, not the directory: the sibling .claude-plugin/marketplace.json is
# compiled output that IS committed and must stay tracked.
/.claude-plugin/plugin.json

View File

@@ -2,7 +2,7 @@
"mcpServers": { "mcpServers": {
"obsidian": { "obsidian": {
"args": [ "args": [
"@bitbonsai/mcpvault@latest", "@bitbonsai/mcpvault@0.15.0",
"docs/" "docs/"
], ],
"command": "npx", "command": "npx",

View File

@@ -89,6 +89,15 @@ repos:
pass_filenames: false pass_filenames: false
always_run: true always_run: true
- id: check-executables-allow-sync
name: Check executables allow key sync
description: Verify root apm.yml's executables.allow key names kyberforge's actual version -- apm matches that key by exact "<package>#<version>" lookup, so a version bump on one side alone silently stops deploying kyberforge's hooks/ and bin/ and lets the apm install go stale (see ADR-0019)
entry: bash scripts/check-executables-allow-sync.sh
language: system
stages: [pre-push]
pass_filenames: false
always_run: true
- id: apm-marketplace-check - id: apm-marketplace-check
name: apm marketplace check name: apm marketplace check
description: Validate every marketplace.packages[] entry resolves, including network reachability of remote refs -- catches stale/unreachable remote package references that check-manifests.sh deliberately skips (local-source checks only) description: Validate every marketplace.packages[] entry resolves, including network reachability of remote refs -- catches stale/unreachable remote package references that check-manifests.sh deliberately skips (local-source checks only)

View File

@@ -31,7 +31,7 @@ This repo dogfoods its own plugins. Before shelling out to git, gitea, or lint t
- Vale prose linting → `vale-config` / `vale-run` - Vale prose linting → `vale-config` / `vale-run`
- This repo's own AGENTS.md → `agentsmd-author` / `agentsmd-audit` - This repo's own AGENTS.md → `agentsmd-author` / `agentsmd-audit`
Names are **unnamespaced**. Under the old `claude plugin install` these were `git:git-commits`, `kyberforge:skill-audit`, and so on; `apm install` deploys each skill to `.claude/skills/<name>/` as a plain project skill, which has no plugin prefix to carry. The `<plugin>:` form no longer resolves here — it still does in any project that installs holocron natively, so a skill body written for both audiences should name the bare skill. Same for agents: `git-orchestrate`, not `git:git-orchestrate`. Use the bare, **unnamespaced** names above. Under the old `claude plugin install` these were `git:git-commits`, `kyberforge:skill-audit`, and so on; `apm install` deploys each skill to `.claude/skills/<name>/` as a plain project skill, which has no plugin prefix to carry. The `<plugin>:` form has not stopped resolving here, though — `~/.claude.json` still enables `core`, `git`, `gitea`, `kyberforge`, and `lint` at **user** scope, and ADR-0018 left those native installs in place on purpose, converting them being a separate decision with a blast radius beyond this repo. Every skill is therefore live under both names right now, and a working `gitea:gitea-prs` is the user-scope copy answering — not evidence that the apm install or this file is broken, and not something to "fix". Prefer the bare name anyway: apm deploys it, an external consumer installing holocron through apm gets it, and it is the form that survives those user-scope installs eventually being converted. The namespaced form also still resolves in any project that installs holocron natively, so a skill body written for both audiences should name the bare skill. Same for agents: `git-orchestrate`, not `git:git-orchestrate`.
Fall back to raw shell only when no skill covers it. Fall back to raw shell only when no skill covers it.
@@ -40,7 +40,7 @@ Fall back to raw shell only when no skill covers it.
- 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/<name>/.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`. - 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/<name>/.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/<name>/.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`. - 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/<name>/.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. - 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 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 14-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 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`). - 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`).
- Install the `vale` binary — required by the `vale-audit-prefilter-skill`/`-agent` pre-commit hooks. Their `files:` patterns are `.apm/`-scoped: `^plugins/[^/]+/\.apm/skills/[^/]+/SKILL\.md$` and `^plugins/[^/]+/\.apm/agents/[^/]+\.agent\.md$`. Only the authoring source triggers them — a `SKILL.md` in the generated mirror matches neither pattern, so prose findings surface only when you edit the file you are supposed to be editing. Without the binary the hooks fail with a bare "command not found" and no install pointer. `brew install vale` (macOS), `snap install vale` (Linux), `choco install vale` (Windows), or see https://vale.sh/docs/vale-cli/installation/. No `vale sync` needed — the `Kyberforge` styles are committed under `plugins/kyberforge/.apm/skills/{skill-audit,agent-audit}/assets/vale/styles/`, not downloaded packages (see ADR-0014). - Install the `vale` binary — required by the `vale-audit-prefilter-skill`/`-agent` pre-commit hooks. Their `files:` patterns are `.apm/`-scoped: `^plugins/[^/]+/\.apm/skills/[^/]+/SKILL\.md$` and `^plugins/[^/]+/\.apm/agents/[^/]+\.agent\.md$`. Only the authoring source triggers them — a `SKILL.md` in the generated mirror matches neither pattern, so prose findings surface only when you edit the file you are supposed to be editing. Without the binary the hooks fail with a bare "command not found" and no install pointer. `brew install vale` (macOS), `snap install vale` (Linux), `choco install vale` (Windows), or see https://vale.sh/docs/vale-cli/installation/. No `vale sync` needed — the `Kyberforge` styles are committed under `plugins/kyberforge/.apm/skills/{skill-audit,agent-audit}/assets/vale/styles/`, not downloaded packages (see ADR-0014).
@@ -48,10 +48,10 @@ Fall back to raw shell only when no skill covers it.
- Run `bash tests/run-tests.sh` before considering any change done — it runs every `test-*.sh` script in the repo plus the bats suite (`--bats-only` for just bats). First run auto-initializes the bats submodules; no manual `git submodule update` needed. - Run `bash tests/run-tests.sh` before considering any change done — it runs every `test-*.sh` script in the repo plus the bats suite (`--bats-only` for just bats). First run auto-initializes the bats submodules; no manual `git submodule update` needed.
- A suite that exits 77 because a dependency is missing is reported as SKIPPED, and does **not** fail an ad-hoc run. The pre-push hook invokes the same script as `--strict` (`RUN_TESTS_STRICT=1` is equivalent), where a skip **does** fail the push: at pre-push a skip means one of the dependencies above is absent on this machine, so the gate would otherwise report success having run fewer suites than it appears to. Without vale, for instance, three suites skip (`test-check-vale-style-sync.sh`, `test-vale-hooks-consumer.sh`, `test-vale-wrap.sh`) and the strict failure names each one and what to install. - A suite that exits 77 because a dependency is missing is reported as SKIPPED, and does **not** fail an ad-hoc run. The pre-push hook invokes the same script as `--strict` (`RUN_TESTS_STRICT=1` is equivalent), where a skip **does** fail the push: at pre-push a skip means one of the dependencies above is absent on this machine, so the gate would otherwise report success having run fewer suites than it appears to. Without vale, for instance, three suites skip (`test-check-vale-style-sync.sh`, `test-vale-hooks-consumer.sh`, `test-vale-wrap.sh`) and the strict failure names each one and what to install.
- `tests/run-bats.sh` derives the set of `.bats` files it expects from `git ls-files`, so a `.bats` file deleted from the worktree but still tracked in the index fails the run rather than silently shrinking the suite. Remove one with `git rm` (or stage the deletion) when the removal is intentional; an untracked new `.bats` file is picked up and needs no ceremony. Both discovery walks (`tests/run-bats.sh` and `tests/run-tests.sh`) exclude `apm_modules/`: `apm install` materializes a full copy of every plugin there, and running a dependency's copy of a `.bats` file breaks its relative path to the bats helpers — 167 spurious failures before the exclusion landed. - `tests/run-bats.sh` derives the set of `.bats` files it expects from `git ls-files`, so a `.bats` file deleted from the worktree but still tracked in the index fails the run rather than silently shrinking the suite. Remove one with `git rm` (or stage the deletion) when the removal is intentional; an untracked new `.bats` file is picked up and needs no ceremony. Both discovery walks (`tests/run-bats.sh` and `tests/run-tests.sh`) exclude `apm_modules/`: `apm install` materializes a full copy of every plugin there, and running a dependency's copy of a `.bats` file breaks its relative path to the bats helpers — 167 spurious failures before the exclusion landed.
- Pushing runs 13 repo-defined pre-push hooks, not just the test suite — `run-tests` and `check-manifests`, plus generated-content drift gates (`check-plugin-content-sync`, `check-marketplace-mirror-sync`, `check-vale-style-sync`, `check-scope-walkup-sync`), artifact validators (`check-apm-agents-valid`, which runs agent-audit's `validate.sh` over every real `plugins/*/.apm/agents/*.agent.md`), apm's own gates (`apm-marketplace-check`, `apm-audit-ci`, `apm-pack-check-clean`), host validators (`validate-plugins`, `validate-marketplace`, both needing the `claude` CLI), and `check-release-needed`. Run `pre-commit run --hook-stage pre-push --all-files` locally — one command, the whole gate. That command reports **15**, not 13: pre-commit's own `meta` hooks, `check-hooks-apply` and `check-useless-excludes`, declare no `stages:` and so run at every stage including this one. - Pushing runs 14 repo-defined pre-push hooks, not just the test suite — `run-tests` and `check-manifests`, plus generated-content drift gates (`check-plugin-content-sync`, `check-marketplace-mirror-sync`, `check-vale-style-sync`, `check-scope-walkup-sync`, `check-executables-allow-sync`), artifact validators (`check-apm-agents-valid`, which runs agent-audit's `validate.sh` over every real `plugins/*/.apm/agents/*.agent.md`), apm's own gates (`apm-marketplace-check`, `apm-audit-ci`, `apm-pack-check-clean`), host validators (`validate-plugins`, `validate-marketplace`, both needing the `claude` CLI), and `check-release-needed`. `check-executables-allow-sync` is the odd one in that first group — it guards a silent failure rather than drift in generated text. apm gates a package's `hooks/` and `bin/` on an exact `<package>#<version>` lookup in root `apm.yml`'s `executables.allow`, with no wildcard and no version-less form, so bumping `plugins/kyberforge/apm.yml`'s `version:` without bumping the key errors nowhere: the entry simply stops matching, kyberforge's `SessionStart` hook stops deploying, and the install goes quietly stale — the failure ADR-0019 records as live. Run `pre-commit run --hook-stage pre-push --all-files` locally — one command, the whole gate. That command reports **16**, not 14: pre-commit's own `meta` hooks, `check-hooks-apply` and `check-useless-excludes`, declare no `stages:` and so run at every stage including this one.
- `apm-audit-ci` runs `apm audit --ci` once per manifest — the root one and each of the six plugin packages — because the root-only invocation audits the marketplace manifest and **nothing else**, and `apm-pack-check-clean` does not parse plugin `dependencies:` blocks either (verified: a malformed one passes `apm pack --check-versions --check-clean --dry-run` and fails `apm audit --ci` in that package's directory). It verifies two things and claims no more: each `apm.yml` parses as a valid APM manifest, and any package declaring dependencies has a consistent `apm.lock.yaml`. It does **not** enforce an org policy — apm discovers one from the git remote and only understands github.com and Azure DevOps, so against this repo's self-hosted Gitea remote it prints `No org policy found at unknown; enforcement skipped`. Do **not** "fix" that with `policy.fetch_failure_default: block` in `apm.yml`: it was tested and rejected, because with no reachable policy source it makes the hook exit 1 on every push forever. - `apm-audit-ci` runs `apm audit --ci` once per manifest — the root one and each of the six plugin packages — because the root-only invocation audits the marketplace manifest and **nothing else**, and `apm-pack-check-clean` does not parse plugin `dependencies:` blocks either (verified: a malformed one passes `apm pack --check-versions --check-clean --dry-run` and fails `apm audit --ci` in that package's directory). It verifies two things and claims no more: each `apm.yml` parses as a valid APM manifest, and any package declaring dependencies has a consistent `apm.lock.yaml`. It does **not** enforce an org policy — apm discovers one from the git remote and only understands github.com and Azure DevOps, so against this repo's self-hosted Gitea remote it prints `No org policy found at unknown; enforcement skipped`. Do **not** "fix" that with `policy.fetch_failure_default: block` in `apm.yml`: it was tested and rejected, because with no reachable policy source it makes the hook exit 1 on every push forever.
- `check-apm-agents-valid` derives its expected agent-file set from `git ls-files` (same pattern as `tests/run-bats.sh`), so an agent file deleted from the worktree but still tracked fails the run, and discovering zero agent files is an error rather than a pass. An untracked new agent file is still validated — the derivation is one-directional on purpose, so uncommitted work is not blocked but also cannot bypass the gate. - `check-apm-agents-valid` derives its expected agent-file set from `git ls-files` (same pattern as `tests/run-bats.sh`), so an agent file deleted from the worktree but still tracked fails the run, and discovering zero agent files is an error rather than a pass. An untracked new agent file is still validated — the derivation is one-directional on purpose, so uncommitted work is not blocked but also cannot bypass the gate.
- **Two** pre-push hooks need the network, for one shared reason: root `apm.yml`'s `marketplace.packages[]` contains exactly one remote entry (`mattpocock-skills`, `source: mattpocock/skills`), and resolving it needs a `git ls-remote`. `apm-marketplace-check` resolves every entry and is `always_run`, so it fails with `No cached refs (offline)`. `apm-pack-check-clean` (`apm pack --check-versions --check-clean --dry-run`) re-resolves the same entry and fails with `Error: Git network timeout during ls-remote`. Pinning the entry to an exact version does **not** remove the call — an exact pin still ls-remotes. `--offline` rescues neither. To push without a network, skip both using pre-commit's own mechanism: `SKIP=apm-marketplace-check,apm-pack-check-clean git push`. Skip those two alone — verified under `unshare -rn`, the other eleven pre-push hooks pass offline because they are real local checks, and adding one of them to `SKIP` disarms it silently. `apm-audit-ci` calls `apm` too but stays local: its org-policy discovery resolves nothing on this remote before any network call, so it does not join the pair above. - **Two** pre-push hooks need the network, for one shared reason: root `apm.yml`'s `marketplace.packages[]` contains exactly one remote entry (`mattpocock-skills`, `source: mattpocock/skills`), and resolving it needs a `git ls-remote`. `apm-marketplace-check` resolves every entry and is `always_run`, so it fails with `No cached refs (offline)`. `apm-pack-check-clean` (`apm pack --check-versions --check-clean --dry-run`) re-resolves the same entry and fails with `Error: Git network timeout during ls-remote`. Pinning the entry to an exact version does **not** remove the call — an exact pin still ls-remotes. `--offline` rescues neither. To push without a network, skip both using pre-commit's own mechanism: `SKIP=apm-marketplace-check,apm-pack-check-clean git push`. Skip those two alone — verified under `unshare -rn`, the other twelve pre-push hooks pass offline because they are real local checks (`check-executables-allow-sync` landed after that run, but reads two local manifests and makes no network call), and adding one of them to `SKIP` disarms it silently. `apm-audit-ci` calls `apm` too but stays local: its org-policy discovery resolves nothing on this remote before any network call, so it does not join the pair above.
- Author commits with `git-commits` — it validates Conventional Commits (enforced at `commit-msg`) for you. - Author commits with `git-commits` — it validates Conventional Commits (enforced at `commit-msg`) for you.
## Key documents ## Key documents

View File

@@ -33,10 +33,10 @@ The deployable unit in the plugin marketplace. A plugin bundles one or more skil
A Git repository with a `marketplace.json` manifest listing installable plugins. No backend, registry, or SaaS required — the Git repo is the marketplace. This repo is the `holocron` marketplace. The manifest at `.claude-plugin/marketplace.json` (read by both Claude Code and Copilot CLI) is **compiled output** of `apm pack`, generated from the root `apm.yml`'s `marketplace:` block (owner, build/output config, versioning strategy, and the `packages:` list of installable plugins) — it is not hand-edited. See ADR-0015. `.github/plugin/marketplace.json` is Copilot CLI's legacy manifest path; apm has no output profile for it (only `claude` and `codex`, and `codex`'s is a differently-shaped file at `.agents/plugins/marketplace.json`), so `scripts/sync-marketplace-mirror.sh` keeps it byte-identical to `.claude-plugin/marketplace.json`, checked at pre-push. Each listed package's `source:` still points at that plugin's own `plugins/<name>/` root, not at an `apm pack` build artifact — which is why that root also carries the flat `agents/`/`skills/`/`commands/`/`hooks/hooks.json` content mirror described under "Plugin" (ADR-0017): without it, an install from this marketplace finds a valid manifest but no discoverable content. A Git repository with a `marketplace.json` manifest listing installable plugins. No backend, registry, or SaaS required — the Git repo is the marketplace. This repo is the `holocron` marketplace. The manifest at `.claude-plugin/marketplace.json` (read by both Claude Code and Copilot CLI) is **compiled output** of `apm pack`, generated from the root `apm.yml`'s `marketplace:` block (owner, build/output config, versioning strategy, and the `packages:` list of installable plugins) — it is not hand-edited. See ADR-0015. `.github/plugin/marketplace.json` is Copilot CLI's legacy manifest path; apm has no output profile for it (only `claude` and `codex`, and `codex`'s is a differently-shaped file at `.agents/plugins/marketplace.json`), so `scripts/sync-marketplace-mirror.sh` keeps it byte-identical to `.claude-plugin/marketplace.json`, checked at pre-push. Each listed package's `source:` still points at that plugin's own `plugins/<name>/` root, not at an `apm pack` build artifact — which is why that root also carries the flat `agents/`/`skills/`/`commands/`/`hooks/hooks.json` content mirror described under "Plugin" (ADR-0017): without it, an install from this marketplace finds a valid manifest but no discoverable content.
### apm-consumed install ### apm-consumed install
How this repo installs its own plugins, as of 2026-08-14: not `claude plugin install <name>@holocron`, but six `dependencies.apm` entries in the root `apm.yml`, each a `git:`/`path:` object against the holocron remote, deployed by `apm install` into `.claude/skills/` and `.claude/agents/`. Project scope only — nothing is installed at user scope, so the switch is contained to this repo and any other repo opts in by declaring its own dependencies. The git+path object form is deliberate over the shorter `<name>@holocron` marketplace alias: an alias must first be registered with `apm marketplace add`, which writes to `~/.apm/marketplaces.json` (user scope, outside the repo), whereas the object form needs nothing beyond the committed manifest and so survives a fresh clone. How this repo installs its own plugins, as of 2026-08-14: not `claude plugin install <name>@holocron`, but six `dependencies.apm` entries in the root `apm.yml`, each a `git:`/`path:` object against the holocron remote, deployed by `apm install` into `.claude/skills/` and `.claude/agents/`. Project scope only — apm installs nothing at user scope, so the switch is contained to this repo and any other repo opts in by declaring its own dependencies. The git+path object form is deliberate over the shorter `<name>@holocron` marketplace alias: an alias must first be registered with `apm marketplace add`, which writes to `~/.apm/marketplaces.json` (user scope, outside the repo), whereas the object form needs nothing beyond the committed manifest and so survives a fresh clone.
Four consequences, each load-bearing: Four consequences, each load-bearing:
- **Skills lose their namespace.** apm deploys plain project skills, so `git:git-commits` is now `git-commits`. The `<plugin>:` form does not resolve in this repo. It still does wherever holocron is installed natively, so cross-audience skill bodies should use the bare name. - **Skills gain an unnamespaced name.** apm deploys plain project skills, so `git:git-commits` also answers to `git-commits`. The `<plugin>:` form has not stopped resolving here: `~/.claude.json` still enables `core`, `git`, `gitea`, `kyberforge`, and `lint` at user scope, which ADR-0018 left in place deliberately — converting them is a separate decision with a blast radius beyond this repo. Until it is taken, every skill is live under two names, which is the same "present twice under two names" outcome ADR-0018's own "Alternatives considered" rejected for *keeping both install paths* — reached here by leaving user scope alone rather than by adopting it as the install model. Write the bare name regardless: apm deploys it, and a repo consuming holocron through apm gets only that form. The namespaced form still resolves wherever holocron is installed natively, so cross-audience skill bodies should use the bare name.
- **apm owns `.claude/settings.json`.** `apm audit --ci` (an `apm-audit-ci` pre-push hook) replays the install into a scratch tree and diffs it against the worktree, so any key apm would not have written is permanent drift. Committed content is exactly `{"hooks": {}}`; repo-owned settings have nowhere to live in that file. - **apm owns `.claude/settings.json`.** `apm audit --ci` (an `apm-audit-ci` pre-push hook) replays the install into a scratch tree and diffs it against the worktree, so any key apm would not have written is permanent drift. Committed content is exactly `{"hooks": {}}`; repo-owned settings have nowhere to live in that file.
- **Install output is gitignored.** `.claude/skills/`, `.claude/agents/`, and `apm_modules/` are all regenerated by `apm install`. `apm.lock.yaml` and the generated `.mcp.json` are committed. Committing the deployed skills would add a third mirror of the same content to the two ADR-0017 already governs. - **Install output is gitignored.** `.claude/skills/`, `.claude/agents/`, and `apm_modules/` are all regenerated by `apm install`. `apm.lock.yaml` and the generated `.mcp.json` are committed. Committing the deployed skills would add a third mirror of the same content to the two ADR-0017 already governs.
- **Test discovery must skip `apm_modules/`.** It holds a full copy of every plugin, `.bats` files included; both `tests/run-bats.sh` and `tests/run-tests.sh` exclude it. - **Test discovery must skip `apm_modules/`.** It holds a full copy of every plugin, `.bats` files included; both `tests/run-bats.sh` and `tests/run-tests.sh` exclude it.

View File

@@ -1,5 +1,5 @@
name: holocron name: holocron
version: 0.4.0 version: 0.4.1
description: AI development skills for Claude Code and GitHub Copilot CLI — factory, design, implement, review, and cross-cutting workflows. description: AI development skills for Claude Code and GitHub Copilot CLI — factory, design, implement, review, and cross-cutting workflows.
license: MIT license: MIT
@@ -52,7 +52,7 @@ marketplace:
# top-level apm.yml description:/version: above are NOT inherited into the # top-level apm.yml description:/version: above are NOT inherited into the
# compiled output despite being used elsewhere (e.g. by `apm audit`). # compiled output despite being used elsewhere (e.g. by `apm audit`).
description: AI development skills for Claude Code and GitHub Copilot CLI — factory, design, implement, review, and cross-cutting workflows. description: AI development skills for Claude Code and GitHub Copilot CLI — factory, design, implement, review, and cross-cutting workflows.
version: 0.4.0 version: 0.4.1
owner: owner:
name: Defame1297 name: Defame1297
email: defame1297@rkdr.net email: defame1297@rkdr.net
@@ -85,7 +85,7 @@ marketplace:
- name: bin - name: bin
description: A place for things to be binned description: A place for things to be binned
source: ./plugins/bin source: ./plugins/bin
version: 1.1.2 version: 1.1.3
category: Utilities category: Utilities
- name: git - name: git

View File

@@ -54,12 +54,23 @@ Three sub-decisions inside that:
## Consequences ## Consequences
**Skills lose their namespace.** apm deploys plain project skills, so `git:git-commits` is now **Skills gain an unnamespaced name.** apm deploys plain project skills, so `git:git-commits` also
`git-commits` and `kyberforge:skill-audit` is `skill-audit`. This is not configurable — a project answers to `git-commits` and `kyberforge:skill-audit` to `skill-audit`. This is not configurable —
skill has no plugin to prefix. Every `<plugin>:<skill>` reference in the repo's own instructions a project skill has no plugin to prefix. `AGENTS.md` and `CONTEXT.md` are updated to name the bare
was stale the moment the switch landed; `AGENTS.md` and `CONTEXT.md` are updated. The namespaced form, which is what apm deploys and the only form a repo consuming holocron through apm gets.
form still resolves for anyone installing holocron natively, so skill bodies written for both
audiences should name the bare skill. **Correction (2026-08-14): the namespaced form did not stop resolving.** An earlier revision of
this consequence said every `<plugin>:<skill>` reference "was stale the moment the switch landed",
and `AGENTS.md`/`CONTEXT.md` were written to match. That contradicts the "User scope is untouched,
deliberately" consequence below, and the contradiction resolves against it: `~/.claude.json` still
enables `core`, `git`, `gitea`, `kyberforge` and `lint` at user scope, so both names are live at
once and a working `gitea:gitea-prs` is the user-scope copy answering. That doubling is the same
"present twice under two names" outcome the "Keeping both install paths" alternative was rejected
for — reached by leaving user scope alone rather than by adopting it, which is why it is a
consequence to record rather than a decision to revisit. Prefer the bare name regardless: it
survives those user-scope installs eventually being converted, and the namespaced 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`.** (ADR-0019 supersedes the "exactly `{"hooks": {}}`" claim **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 below — once a package ships a hook, apm merges it into that file and the merged entry is apm's own

View File

@@ -70,8 +70,31 @@ this machine still has it open.
immediately, since bumping kyberforge to 1.5.0 required editing the key in the same commit. A 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 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 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 to end, reintroduced through the mechanism meant to secure it.
comment saying to check it first when skills go stale after a release.
Matching is an exact dictionary lookup on the composed `name#version` string
(`apm_cli/security/executables.py`, `is_package_approved`), so there is no wildcard or
version-less key that would sidestep this — the key has to be edited on every bump, and the
question is only what catches a missed edit. A comment in the `executables:` block is not enough:
this repo gates generated-content drift, marketplace mirror drift and vale style drift
deterministically, and a silent-staleness failure is strictly worse than any of them. So
`scripts/check-executables-allow-sync.sh` runs at pre-push, parsing `version:` out of
`plugins/kyberforge/apm.yml` and asserting root `apm.yml` carries the matching
`kyberforge#<version>` key. The comment stays as the human-facing pointer; the hook is what
actually holds. It parses with PyYAML where importable and falls back to a two-shape scan
otherwise, so a missing pip package cannot become the thing that blocks every push.
**Trust is keyed on the version, not on the content.** `kyberforge#1.5.0` approves whatever
`check-apm-current.sh` contains at the moment it is fetched, not the bytes that were reviewed when
the key was written. Because the dependency is unpinned against the default branch and the hook
runs `apm update --yes` unattended, an edit to that script landing on `main` deploys and executes
on every contributor's machine at their next session start, with no second approval prompt and no
diff shown. The trust gate constrains *which package* may ship an executable; it does not constrain
what that executable does between version bumps. That is an accepted property of this design rather
than an oversight — the remote is self-hosted, push access to `main` is already sufficient to
change any skill body an agent will follow — but it is the reason the gate should not be read as a
supply-chain control. Pinning each dependency to a `ref:` is what would make it one, and ADR-0018
defers that until per-package release tags exist.
**A referenced hook script must be addressed at its `.apm/` path.** apm resolves **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` `${CLAUDE_PLUGIN_ROOT}/...` against the installed package root, and `apm pack` keeps only `*.json`
@@ -84,7 +107,30 @@ A test pins the reference.
**Session startup gets slower when the install is stale.** Measured: ~0.7 s for the `apm outdated` **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 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. hook declares `timeout: 380` to cover a cold multi-package fetch. That number is not free-standing:
the script imposes its own `timeout 60` on `apm outdated` and `timeout 300` on `apm update`, so the
host-side timeout has to exceed their sum or the host kills the hook mid-update and leaves
`.claude/skills/` half-deployed with no notice emitted. An earlier revision declared `320`, which
was below the 360 s the script can legitimately take. A test asserts the invariant rather than the
literal — it parses every `timeout N` out of the script, sums them, and requires the `hooks.json`
value to be larger — so changing either side without the other fails the suite.
**Reading a human-readable CLI for a control decision cost a silent failure, again.** `apm outdated`
has no `--json` or other machine-readable flag (confirmed against 0.28.0), so the hook must match
its prose. The first attempt matched `outdated dependencies found` — plural only. apm emits
`1 outdated dependency found` in the singular when exactly one package is behind
(`apm_cli/commands/outdated.py`), so a single stale package was invisible: the hook exited 0
silently and no refresh ran. With six packages merging independently, one-behind is the ordinary
case rather than an edge, which means the mechanism failed most often in exactly the situation it
exists for. The match is now `outdated dependenc(y|ies) found`.
The deeper lesson is the one `post-push` already taught and this repeated: every assertion about the
hook mocked `apm`, so the suite was green while the hook could not detect the common case. Mocks
verify the code against its author's belief about the interface, never the interface. The suite now
carries one probe that stages a genuinely outdated dependency against a local git remote — offline,
via `url.<path>.insteadOf`, so the twelve-hooks-pass-under-`unshare -rn` property survives — runs
the real `apm outdated`, and replays its genuine output through the real hook. Reverting the grep
to plural-only fails it.
**The hook cannot install itself.** Dependencies resolve from the remote, so the hook does not **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. deploy until this change is merged and `apm update` has run once against the new default branch.

View File

@@ -1,6 +1,6 @@
{ {
"name": "bin", "name": "bin",
"version": "1.1.2", "version": "1.1.3",
"description": "A place for things to be binned", "description": "A place for things to be binned",
"author": { "author": {
"name": "Defame1297", "name": "Defame1297",
@@ -20,7 +20,7 @@
"mcpServers": { "mcpServers": {
"obsidian": { "obsidian": {
"args": [ "args": [
"@bitbonsai/mcpvault@latest", "@bitbonsai/mcpvault@0.15.0",
"docs/" "docs/"
], ],
"command": "npx", "command": "npx",

View File

@@ -1,6 +1,6 @@
{ {
"name": "bin", "name": "bin",
"version": "1.1.2", "version": "1.1.3",
"description": "A place for things to be binned", "description": "A place for things to be binned",
"author": { "author": {
"name": "Defame1297", "name": "Defame1297",

View File

@@ -2,7 +2,7 @@
"mcpServers": { "mcpServers": {
"obsidian": { "obsidian": {
"args": [ "args": [
"@bitbonsai/mcpvault@latest", "@bitbonsai/mcpvault@0.15.0",
"docs/" "docs/"
], ],
"command": "npx", "command": "npx",

View File

@@ -33,7 +33,7 @@ copilot plugin install ./plugins/bin
| Component | Path | Description | | Component | Path | Description |
|---|---|---| |---|---|---|
| Skills | `.apm/skills/` → `skills/` | Slash commands available after install | | Skills | `.apm/skills/` → `skills/` | Slash commands available after install |
| MCP servers | `.mcp.json` | The `obsidian` server (`npx @bitbonsai/mcpvault@latest docs/`), hand-authored at the plugin root | | MCP servers | `.mcp.json` | The `obsidian` server (`npx @bitbonsai/mcpvault@0.15.0 docs/`), hand-authored at the plugin root |
`.apm/` is the authoring source; `skills/` is the generated mirror plugin hosts scan (ADR-0017). This plugin ships no agents. It is the only plugin here with a non-empty `.mcp.json`, which is why its compiled manifests are the only ones carrying an `mcpServers` block. `.apm/` is the authoring source; `skills/` is the generated mirror plugin hosts scan (ADR-0017). This plugin ships no agents. It is the only plugin here with a non-empty `.mcp.json`, which is why its compiled manifests are the only ones carrying an `mcpServers` block.

View File

@@ -1,5 +1,5 @@
name: bin name: bin
version: 1.1.2 version: 1.1.3
description: A place for things to be binned description: A place for things to be binned
author: author:
name: Defame1297 name: Defame1297

View File

@@ -13,18 +13,36 @@
# Inert in any project that does not consume packages through apm. # Inert in any project that does not consume packages through apm.
set -uo pipefail 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 # No lockfile means nothing was installed through apm here — e.g. a host that
# installed this plugin natively. Say nothing and cost nothing. # 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 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 # `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 # come from its output. ~0.7s against six remote refs; a hung remote must not
# hold the session open. # 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 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" [[ "$stale_count" =~ ^[0-9]+$ ]] || stale_count="some"
# Only ever emit fixed text plus a digit-checked count — never interpolate # Only ever emit fixed text plus a digit-checked count — never interpolate

View File

@@ -5,7 +5,7 @@
"hooks": [ "hooks": [
{ {
"command": "${CLAUDE_PLUGIN_ROOT}/.apm/hooks/check-apm-current.sh", "command": "${CLAUDE_PLUGIN_ROOT}/.apm/hooks/check-apm-current.sh",
"timeout": 320, "timeout": 380,
"type": "command" "type": "command"
} }
], ],

View File

@@ -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`, `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 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 session picks up the redeployed content. Rationale, measurements, and the failure modes are in
working directory, which makes it inert for any host that installed this plugin natively rather than ADR-0019.
through apm. 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 ## GitHub Copilot CLI

View File

@@ -5,7 +5,7 @@
"hooks": [ "hooks": [
{ {
"command": "${CLAUDE_PLUGIN_ROOT}/.apm/hooks/check-apm-current.sh", "command": "${CLAUDE_PLUGIN_ROOT}/.apm/hooks/check-apm-current.sh",
"timeout": 320, "timeout": 380,
"type": "command" "type": "command"
} }
], ],

View File

@@ -0,0 +1,231 @@
#!/usr/bin/env bash
set -euo pipefail
# Fails the push when root apm.yml's executables.allow key stops naming
# kyberforge's actual version.
#
# apm gates a package's hooks/ and bin/ on an EXACT dict lookup of
# "<package>#<version>" in executables.allow (apm_cli/security/executables.py,
# `allow_executables.get(package_key)`) — there is no wildcard and no
# version-less form. So bumping plugins/kyberforge/apm.yml's `version:` without
# bumping the key in root apm.yml does not error anywhere: the entry simply
# stops matching, kyberforge's SessionStart hook stops deploying, and the apm
# install goes quietly stale — the exact failure ADR-0019 records as live and
# mitigates only with a comment. Nothing else in the pre-push gate compares the
# two files, which is why this exists.
#
# Run from repo root or pass REPO_ROOT as arg.
REPO_ROOT="${1:-$(git rev-parse --show-toplevel 2>/dev/null || pwd)}"
# A nonexistent REPO_ROOT fails loudly rather than falling through to the
# "no kyberforge plugin here" no-op below: that no-op is for a repo that
# legitimately does not ship the plugin, not for a typo'd or stale path, and
# exit 0 would read as "checked, in sync" when nothing was compared at all.
if [[ ! -d "$REPO_ROOT" ]]; then
echo "FAIL: executables-allow sync check: REPO_ROOT '$REPO_ROOT' is not a directory." >&2
echo " Fix: run this from the repo root, or pass a real repo path as the first argument." >&2
exit 1
fi
REPO_ROOT="$(cd "$REPO_ROOT" && pwd)"
PLUGIN_DIR="$REPO_ROOT/plugins/kyberforge"
PLUGIN_MANIFEST="$PLUGIN_DIR/apm.yml"
ROOT_MANIFEST="$REPO_ROOT/apm.yml"
# Only a repo with no kyberforge plugin at all is a legitimate no-op — there is
# no version to pin and no hook to deploy.
if [[ ! -d "$PLUGIN_DIR" ]]; then
exit 0
fi
if [[ ! -f "$PLUGIN_MANIFEST" ]]; then
echo "FAIL: plugins/kyberforge/ exists but has no apm.yml, so its version cannot be read." >&2
echo " Why: this gate compares that version against root apm.yml's executables.allow key;" >&2
echo " without the manifest it would exit 0 having compared nothing." >&2
echo " Fix: restore plugins/kyberforge/apm.yml, or remove plugins/kyberforge/ entirely." >&2
exit 1
fi
if [[ ! -f "$ROOT_MANIFEST" ]]; then
echo "FAIL: root apm.yml is missing, so kyberforge's executables allow key cannot be verified." >&2
echo " Why: apm reads executables.allow from the consuming package's manifest; with no root" >&2
echo " manifest nothing grants kyberforge's hooks/ and bin/ permission to deploy." >&2
echo " Fix: restore apm.yml at the repo root." >&2
exit 1
fi
# ---------------------------------------------------------------------------
# Manifest facts
# ---------------------------------------------------------------------------
# Both readers emit the same line protocol, so the rest of the script does not
# care which ran:
#
# v<TAB><kyberforge version> exactly once
# a<TAB>present|absent exactly once — is executables.allow a mapping?
# k<TAB><allow key> zero or more
#
# python3 + PyYAML is preferred where importable, because it is a real parser.
# It is deliberately NOT a hard requirement: no other hook in this repo's
# pre-push gate needs PyYAML, and making a version-pin check the one thing that
# can block every push on a missing pip package is a worse failure than reading
# two known shapes by hand. The fallback below is not a YAML parser — it
# recognises exactly the two shapes these manifests use and nothing else.
read_facts_python() {
python3 - "$ROOT_MANIFEST" "$PLUGIN_MANIFEST" << 'PY'
import sys
import yaml
def load(path):
with open(path) as fh:
data = yaml.safe_load(fh)
return data if isinstance(data, dict) else {}
root, plugin = load(sys.argv[1]), load(sys.argv[2])
version = plugin.get("version")
# A 2-component version parses as a YAML float and would render back as e.g.
# "1.5" from 1.50 — a mismatch invented by the reader rather than found in the
# files. Refuse instead of guessing; semver keys here are always strings.
if version is not None and not isinstance(version, str):
sys.stderr.write(
"kyberforge's version: is not a string (%r) — quote it so the "
"executables.allow key can be compared verbatim.\n" % (version,)
)
raise SystemExit(2)
print("v\t%s" % ("" if version is None else version))
executables = root.get("executables")
allow = executables.get("allow") if isinstance(executables, dict) else None
if isinstance(allow, dict):
print("a\tpresent")
for key in allow:
print("k\t%s" % key)
else:
print("a\tabsent")
PY
}
# Strips one layer of matching quotes plus surrounding whitespace from a scalar.
unquote() {
local value="$1"
read -r value <<< "$value"
case "$value" in
\"*\") value="${value#\"}"; value="${value%\"}" ;;
\'*\') value="${value#\'}"; value="${value%\'}" ;;
esac
printf '%s\n' "$value"
}
read_facts_bash() {
local line version="" allow_state="absent" in_executables=0 in_allow=0 key
while IFS= read -r line || [[ -n "$line" ]]; do
[[ "$line" == version:* ]] || continue
version="$(unquote "${line#version:}")"
break
done < "$PLUGIN_MANIFEST"
while IFS= read -r line || [[ -n "$line" ]]; do
# Blank and full-line comments carry no structure at any depth.
case "$line" in
'' | '#'*) continue ;;
esac
# A top-level key (column 0) closes whatever block was open. Checked before
# anything else so `marketplace:` after `executables:` cannot leak keys in.
if [[ "$line" != [[:space:]]* ]]; then
if [[ "$line" == executables:* ]]; then
in_executables=1
else
in_executables=0
fi
in_allow=0
continue
fi
[[ $in_executables -eq 1 ]] || continue
# 2-space indent: a key directly under executables:. `allow:` opens the
# mapping this gate reads; any sibling key closes it.
if [[ "$line" =~ ^\ \ [^[:space:]#] ]]; then
if [[ "$line" =~ ^\ \ allow: ]]; then
in_allow=1
allow_state="present"
else
in_allow=0
fi
continue
fi
# 4-space indent while inside allow: — an allow key. Keys contain '#' by
# construction ("kyberforge#1.5.0"), so a trailing-comment strip would eat
# them; there is none, and inline comments are not used on these lines.
if [[ $in_allow -eq 1 && "$line" =~ ^\ \ \ \ ([^[:space:]#][^:]*): ]]; then
key="$(unquote "${BASH_REMATCH[1]}")"
printf 'k\t%s\n' "$key"
fi
done < "$ROOT_MANIFEST"
printf 'v\t%s\n' "$version"
printf 'a\t%s\n' "$allow_state"
}
if command -v python3 > /dev/null 2>&1 && python3 -c 'import yaml' > /dev/null 2>&1; then
READER="python3 + PyYAML"
if ! FACTS="$(read_facts_python)"; then
echo "FAIL: could not read apm.yml / plugins/kyberforge/apm.yml (see the parser error above)." >&2
echo " Why: this gate compares kyberforge's version against root apm.yml's executables.allow" >&2
echo " key; an unreadable manifest means the comparison did not happen." >&2
echo " Fix: make both manifests valid YAML, then re-run." >&2
exit 1
fi
else
READER="shape-scan fallback (PyYAML unavailable)"
FACTS="$(read_facts_bash)"
fi
VERSION="$(printf '%s\n' "$FACTS" | sed -n 's/^v\t//p')"
ALLOW_STATE="$(printf '%s\n' "$FACTS" | sed -n 's/^a\t//p')"
ALLOW_KEYS="$(printf '%s\n' "$FACTS" | sed -n 's/^k\t//p')"
if [[ -z "$VERSION" ]]; then
echo "FAIL: plugins/kyberforge/apm.yml declares no version:, so no allow key can be checked against it." >&2
echo " Why: apm's executables.allow lookup is keyed on '<package>#<version>' exactly; with no" >&2
echo " version there is nothing for root apm.yml's key to stay in sync with." >&2
echo " Fix: give plugins/kyberforge/apm.yml a top-level version: (read by $READER)." >&2
exit 1
fi
EXPECTED_KEY="kyberforge#$VERSION"
if [[ "$ALLOW_STATE" != "present" ]]; then
echo "FAIL: root apm.yml has no executables.allow mapping, but plugins/kyberforge is version $VERSION." >&2
echo " Why: without an allow entry apm refuses to deploy kyberforge's hooks/ and bin/, so the" >&2
echo " SessionStart hook that keeps this install level with the remote never runs and the" >&2
echo " deployed skills go stale silently (ADR-0019)." >&2
echo " Fix: add to root apm.yml:" >&2
echo " executables:" >&2
echo " allow:" >&2
echo " $EXPECTED_KEY:" >&2
echo " hooks: true" >&2
echo " bin: true" >&2
exit 1
fi
if printf '%s\n' "$ALLOW_KEYS" | grep -qxF "$EXPECTED_KEY"; then
exit 0
fi
STALE_KEYS="$(printf '%s\n' "$ALLOW_KEYS" | grep '^kyberforge#' || true)"
echo "FAIL: root apm.yml's executables.allow has no '$EXPECTED_KEY' key, but that is kyberforge's version." >&2
if [[ -n "$STALE_KEYS" ]]; then
echo " Found instead:" >&2
printf '%s\n' "$STALE_KEYS" | sed 's/^/ /' >&2
fi
echo " Why: apm matches this key by exact dict lookup — there is no wildcard and no version-less" >&2
echo " form — so a key naming any other version silently stops granting kyberforge's hooks/" >&2
echo " and bin/. The SessionStart hook then stops deploying and the apm install goes stale" >&2
echo " with no error anywhere (ADR-0019, 'The allow key is version-pinned')." >&2
echo " Fix: bump the key in root apm.yml to '$EXPECTED_KEY:' — the version bump in" >&2
echo " plugins/kyberforge/apm.yml is not complete without it." >&2
exit 1

View File

@@ -23,11 +23,14 @@ WORK="$(mktemp -d)"
trap 'rm -rf "$FAKE_BIN" "$WORK"' EXIT trap 'rm -rf "$FAKE_BIN" "$WORK"' EXIT
# Mock `apm`. $1 chooses what `apm outdated` reports; $2 the exit code of # Mock `apm`. $1 chooses what `apm outdated` reports; $2 the exit code of
# `apm update`. A sentinel file records whether update was actually invoked. # `apm update`. Sentinel files record whether update was actually invoked and
# which directory the calls ran in — the hook must run them against the same
# directory its lockfile guard checked, not the session's cwd.
make_apm() { make_apm() {
local outdated_line="$1" update_exit="$2" local outdated_line="$1" update_exit="$2"
cat > "$FAKE_BIN/apm" << EOF cat > "$FAKE_BIN/apm" << EOF
#!/usr/bin/env bash #!/usr/bin/env bash
pwd > "$WORK/apm-cwd"
case "\$1" in case "\$1" in
outdated) echo "$outdated_line"; exit 0 ;; outdated) echo "$outdated_line"; exit 0 ;;
update) touch "$WORK/update-was-called"; exit $update_exit ;; update) touch "$WORK/update-was-called"; exit $update_exit ;;
@@ -37,7 +40,22 @@ EOF
chmod +x "$FAKE_BIN/apm" chmod +x "$FAKE_BIN/apm"
} }
run_hook() { (cd "$WORK" && PATH="$FAKE_BIN:$PATH" bash "$HOOK" 2>/dev/null); } # CLAUDE_PROJECT_DIR is cleared rather than merely left alone: a session in this
# repo exports it, and an inherited value would point every case at the real
# repo root (which has a real apm.lock.yaml) instead of the fixture. The
# project-directory cases below set it deliberately.
run_hook() { (cd "$WORK" && env -u CLAUDE_PROJECT_DIR PATH="$FAKE_BIN:$PATH" bash "$HOOK" 2>/dev/null); }
# Same, with an explicit cwd and CLAUDE_PROJECT_DIR. $1 is the cwd; $2 the value
# for CLAUDE_PROJECT_DIR, or the literal `-` to leave it unset.
run_hook_in() {
local cwd="$1" project_dir="$2"
if [[ "$project_dir" == "-" ]]; then
(cd "$cwd" && env -u CLAUDE_PROJECT_DIR PATH="$FAKE_BIN:$PATH" bash "$HOOK" 2>/dev/null)
else
(cd "$cwd" && env CLAUDE_PROJECT_DIR="$project_dir" PATH="$FAKE_BIN:$PATH" bash "$HOOK" 2>/dev/null)
fi
}
json_field() { python3 -c 'import json,sys; print(json.load(sys.stdin)["hookSpecificOutput"][sys.argv[1]])' "$1"; } json_field() { python3 -c 'import json,sys; print(json.load(sys.stdin)["hookSpecificOutput"][sys.argv[1]])' "$1"; }
@@ -134,6 +152,54 @@ out="$(run_hook)"
echo "$out" | python3 -m json.tool > /dev/null 2>&1 \ 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" && pass "still emits valid JSON when the count cannot be parsed" || fail "JSON broke on an unparseable count"
# ---------------------------------------------------------------------------
echo ""
echo "--- anchors on the project root, not the session cwd ---"
# ---------------------------------------------------------------------------
# `[[ -f apm.lock.yaml ]]` resolves against the cwd, and a session started in a
# subdirectory of an apm-consuming repo therefore no-opped silently — and would
# have run `apm outdated`/`apm update` against that wrong directory had the
# guard passed. Claude Code exports CLAUDE_PROJECT_DIR for SessionStart hooks,
# so that is the anchor; the cwd is only the fallback.
ELSEWHERE="$WORK/elsewhere"
mkdir -p "$ELSEWHERE"
rm -f "$ELSEWHERE/apm.lock.yaml"
make_apm "[!] 6 outdated dependencies found" 0
rm -f "$WORK/update-was-called" "$WORK/apm-cwd"
out="$(run_hook_in "$ELSEWHERE" "$WORK")"
[[ -f "$WORK/update-was-called" ]] \
&& pass "finds the lockfile via CLAUDE_PROJECT_DIR when the cwd is elsewhere" \
|| fail "a session started in a subdirectory must still see the project's lockfile"
[[ "$(cat "$WORK/apm-cwd" 2>/dev/null)" == "$WORK" ]] \
&& pass "runs apm in the directory the guard checked, not the cwd" \
|| fail "apm ran in '$(cat "$WORK/apm-cwd" 2>/dev/null)' — must run in the resolved project directory"
echo "$out" | json_field additionalContext | grep -q "6 package" \
&& pass "reports the count found via CLAUDE_PROJECT_DIR" || fail "should report the count"
# The fallback is not cosmetic: a host that installed this plugin natively sets
# no CLAUDE_PROJECT_DIR, and the hook must stay inert-but-harmless there rather
# than erroring on an unset variable (the script runs under `set -u`).
rm -f "$WORK/update-was-called" "$WORK/apm-cwd"
out="$(run_hook_in "$WORK" "-")"
[[ -f "$WORK/update-was-called" ]] \
&& pass "falls back to the cwd when CLAUDE_PROJECT_DIR is unset" \
|| fail "must still work with no CLAUDE_PROJECT_DIR in the environment"
[[ "$(cat "$WORK/apm-cwd" 2>/dev/null)" == "$WORK" ]] \
&& pass "runs apm in the cwd under the fallback" \
|| fail "apm ran in '$(cat "$WORK/apm-cwd" 2>/dev/null)' — should be the cwd"
rm -f "$WORK/update-was-called" "$WORK/apm-cwd"
out="$(run_hook_in "$ELSEWHERE" "$ELSEWHERE")"; rc=$?
[[ $rc -eq 0 ]] && pass "exits 0 when neither the project dir nor the cwd has a lockfile" \
|| fail "should exit 0 when there is no lockfile anywhere"
[[ -z "$out" ]] && pass "stays silent when neither the project dir nor the cwd has a lockfile" \
|| fail "should stay silent when there is no lockfile anywhere"
[[ ! -f "$WORK/update-was-called" ]] \
&& pass "does not run apm update when there is no lockfile anywhere" \
|| fail "must not touch a project that does not use apm"
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
echo "" echo ""
echo "--- hooks.json wiring ---" echo "--- hooks.json wiring ---"
@@ -153,7 +219,150 @@ matcher="$(python3 -c 'import json,sys; d=json.load(open(sys.argv[1])); print(d[
[[ "$matcher" == "startup" ]] && pass "fires on startup only" \ [[ "$matcher" == "startup" ]] && pass "fires on startup only" \
|| fail "matcher is '$matcher' — resume/clear/compact would re-run this every compaction" || fail "matcher is '$matcher' — resume/clear/compact would re-run this every compaction"
# The host's timeout must strictly exceed everything the script can spend, or
# the host SIGKILLs the hook mid-`apm update` and leaves a half-redeployed
# .claude/skills/ with no notice emitted — the silent failure this hook exists
# to prevent. Asserted as an invariant over both files rather than against a
# literal, so raising either internal `timeout` without raising the host budget
# fails here instead of reintroducing the gap quietly.
#
# Every `timeout N` in the script counts, comments included: a stray "timeout
# 300" in prose only makes this stricter, which is the safe direction.
script_budget=0
timeout_count=0
while read -r n; do
[[ -n "$n" ]] || continue
script_budget=$((script_budget + n))
timeout_count=$((timeout_count + 1))
done < <(grep -oE '\btimeout [0-9]+\b' "$HOOK" | grep -oE '[0-9]+')
hook_timeout="$(python3 -c 'import json,sys; d=json.load(open(sys.argv[1])); print(d["hooks"]["SessionStart"][0]["hooks"][0]["timeout"])' "$HOOKS_JSON")"
if [[ $timeout_count -eq 0 ]]; then
fail "found no 'timeout N' in $HOOK — the budget assertion below would be vacuous"
else
pass "parsed $timeout_count internal timeout(s) totalling ${script_budget}s from the hook script"
[[ $hook_timeout -gt $script_budget ]] \
&& pass "hooks.json timeout (${hook_timeout}s) exceeds the script's own budget (${script_budget}s)" \
|| fail "hooks.json timeout is ${hook_timeout}s but the script can spend ${script_budget}s — the host would SIGKILL it mid-update"
fi
# ---------------------------------------------------------------------------
echo ""
echo "--- the greped phrase, against the real apm ---"
# ---------------------------------------------------------------------------
# Everything above mocks `apm`, so an apm release that reworded its summary line
# would revert this hook to its pre-fix behaviour with a fully green suite.
# `apm outdated` has no --json/machine-readable flag (verified against 0.28.0),
# so the phrase match cannot be replaced — it can only be pinned.
#
# The probe stages a genuinely outdated dependency with no network: a local git
# repo stands in for the upstream, reached through `url.<path>.insteadOf`
# rewrites of every URL spelling apm may build (it picks ssh or https depending
# on ambient auth config, so all three are mapped). apm appends `.git` to the
# repo URL, which is why the local repo is named `upstream.git` and the rewrite
# target omits the suffix. HOME is redirected so no user-level apm cache or
# credential state can influence the result.
#
# The genuine output is then replayed into the real hook through the mock, so
# what is asserted is the hook's own matching logic against real apm text —
# no pattern is duplicated here to drift out of sync.
SKIP_REASON=""
if ! command -v apm > /dev/null 2>&1 || ! command -v git > /dev/null 2>&1; then
SKIP_REASON="SKIP: apm and git are both required to verify the hook's phrase match against real \`apm outdated\` output — everything above ran, this axis did not"
echo " $SKIP_REASON"
else
PROBE="$(mktemp -d)"
trap 'rm -rf "$FAKE_BIN" "$WORK" "$PROBE"' EXIT
UPSTREAM="$PROBE/upstream.git"
git init -q "$UPSTREAM"
git -C "$UPSTREAM" -c user.email=probe@example.invalid -c user.name=probe \
commit -q --allow-empty -m one
LOCKED_SHA="$(git -C "$UPSTREAM" rev-parse HEAD)"
git -C "$UPSTREAM" -c user.email=probe@example.invalid -c user.name=probe \
commit -q --allow-empty -m two
BRANCH="$(git -C "$UPSTREAM" symbolic-ref --short HEAD)"
{
for repo in alpha beta; do
printf '[url "%s/upstream"]\n' "$PROBE"
printf '\tinsteadOf = git@apm-probe.invalid:probe/%s\n' "$repo"
printf '\tinsteadOf = https://apm-probe.invalid/probe/%s\n' "$repo"
printf '\tinsteadOf = ssh://git@apm-probe.invalid/probe/%s\n' "$repo"
done
} > "$PROBE/gitconfig"
mkdir -p "$PROBE/consumer" "$PROBE/home"
# $1 = how many stale dependencies to stage. Writes a lockfile and echoes what
# the real `apm outdated` printed for it.
real_apm_outdated() {
local want="$1" repo
{
echo "lockfile_version: '1'"
echo "generated_at: '2026-01-01T00:00:00+00:00'"
echo "apm_version: 0.0.0"
echo "dependencies:"
for repo in $( [[ "$want" == 1 ]] && echo alpha || echo alpha beta ); do
echo "- host: apm-probe.invalid"
echo " name: probe-$repo"
echo " package_type: apm_package"
echo " repo_url: probe/$repo"
echo " resolved_ref: $BRANCH"
echo " resolved_commit: $LOCKED_SHA"
echo " version: 1.0.0"
done
echo "deployments: []"
} > "$PROBE/consumer/apm.lock.yaml"
(
cd "$PROBE/consumer" &&
env HOME="$PROBE/home" \
GIT_CONFIG_GLOBAL="$PROBE/gitconfig" \
GIT_CONFIG_NOSYSTEM=1 \
GIT_TERMINAL_PROMPT=0 \
apm outdated 2>&1
)
}
# Replay genuine output through the hook. A harness that stages nothing would
# make every assertion below vacuously true, so the staged row is checked
# first and a failure to stage is a FAIL, not a quiet pass.
for want in 1 2; do
genuine="$(real_apm_outdated "$want" || true)"
if ! grep -q "outdated" <<< "$genuine"; then
fail "probe staged no outdated dependency against the real apm (harness broken, not the hook): $(tr '\n' ' ' <<< "$genuine" | cut -c1-160)"
continue
fi
printf '%s\n' "$genuine" > "$PROBE/genuine-$want.txt"
cat > "$FAKE_BIN/apm" << EOF
#!/usr/bin/env bash
pwd > "$WORK/apm-cwd"
case "\$1" in
outdated) cat "$PROBE/genuine-$want.txt"; exit 0 ;;
update) touch "$WORK/update-was-called"; exit 0 ;;
esac
exit 0
EOF
chmod +x "$FAKE_BIN/apm"
rm -f "$WORK/update-was-called"
out="$(run_hook)"
if [[ -n "$out" ]] && echo "$out" | json_field additionalContext 2>/dev/null | grep -q "$want package"; then
pass "detects staleness in real \`apm outdated\` output and counts $want package(s)"
else
fail "real apm reported $want outdated dependency/dependencies but the hook did not act on it — apm reworded its summary line. Real output: $(tr '\n' ' ' < "$PROBE/genuine-$want.txt" | tail -c 120)"
fi
done
fi
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
echo "" echo ""
echo "Results: $PASS passed, $FAIL failed" echo "Results: $PASS passed, $FAIL failed"
[[ $FAIL -eq 0 ]] [[ $FAIL -eq 0 ]] || exit 1
# A skip only after everything runnable has run and passed: the mocked axis is
# still worth executing on a machine without apm, but the suite must not read
# as green when the real-apm axis was not verified. run-tests.sh reports 77 as
# SKIPPED and, at pre-push (--strict), as a setup error naming this reason.
[[ -z "$SKIP_REASON" ]] || exit 77
exit 0

View File

@@ -0,0 +1,243 @@
#!/usr/bin/env bash
# Tests for scripts/check-executables-allow-sync.sh — the pre-push gate that
# keeps root apm.yml's executables.allow key level with kyberforge's version.
#
# Fixtures are two-file skeletons (root apm.yml + plugins/kyberforge/apm.yml)
# rather than copies of the real repo: the gate reads exactly those two files,
# and a hand-built fixture is the only way to construct the drift it exists to
# catch without editing the real manifests.
set -euo pipefail
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
SCRIPT="$REPO_ROOT/scripts/check-executables-allow-sync.sh"
PASS=0
FAIL=0
pass() { echo " PASS: $1"; PASS=$((PASS + 1)); }
fail() { echo " FAIL: $1"; FAIL=$((FAIL + 1)); }
# Same `exit 77` (automake convention; run-tests.sh renders it as SKIPPED) guard
# the vale suites use. The script itself runs fine without python3 — it falls
# back to a shape scan — but this suite asserts BOTH readers agree, and the
# PyYAML path cannot be exercised at all on a machine without it. Reporting
# those cases as failures would say "a regression landed" when the truth is
# "this machine is missing a dev dependency".
command -v python3 > /dev/null 2>&1 || { echo "SKIP: python3 is required to exercise the PyYAML reader"; exit 77; }
python3 -c 'import yaml' > /dev/null 2>&1 || { echo "SKIP: PyYAML is required to exercise the PyYAML reader"; exit 77; }
FIXTURES=()
cleanup() { [[ ${#FIXTURES[@]} -eq 0 ]] || rm -rf "${FIXTURES[@]}"; }
trap cleanup EXIT
# make_fixture <plugin-version-line> <root-executables-block>
# The executables block is passed verbatim (may be empty) so a fixture can omit
# it entirely, which is one of the failure modes under test.
make_fixture() {
local version_line="$1" executables_block="$2" dir
dir="$(mktemp -d)"
FIXTURES+=("$dir")
mkdir -p "$dir/plugins/kyberforge"
{
echo "name: kyberforge"
echo "$version_line"
echo "description: fixture"
} > "$dir/plugins/kyberforge/apm.yml"
{
echo "name: ai-development"
echo "version: 0.0.1"
echo "dependencies:"
echo " apm:"
echo " - name: kyberforge"
[[ -n "$executables_block" ]] && printf '%s\n' "$executables_block"
# A top-level key after the block: the fallback reader must stop collecting
# allow keys here rather than reading on into the next section.
echo "marketplace:"
echo " owner:"
echo " name: fixture"
} > "$dir/apm.yml"
printf '%s\n' "$dir"
}
MATCHING_BLOCK='executables:
allow:
kyberforge#1.5.0:
hooks: true
bin: true'
STALE_BLOCK='executables:
allow:
kyberforge#1.4.0:
hooks: true
bin: true'
OTHER_PACKAGE_BLOCK='executables:
allow:
git#1.0.0:
hooks: true'
# run_gate <fixture> — echoes combined output, sets GATE_RC.
GATE_RC=0
run_gate() {
GATE_RC=0
bash "$SCRIPT" "$1" > /dev/null 2>&1 || GATE_RC=$?
}
# ---------------------------------------------------------------------------
echo "--- the real repo passes ---"
# ---------------------------------------------------------------------------
# The gate's whole value is that it is green on a correct tree and red on drift;
# a version bump landing in only one of the two real manifests must show up here.
run_gate "$REPO_ROOT"
[[ $GATE_RC -eq 0 ]] && pass "current repo state passes" \
|| fail "current repo state should pass — the gate said: $(bash "$SCRIPT" "$REPO_ROOT" 2>&1 | head -3)"
# ---------------------------------------------------------------------------
echo ""
echo "--- matching version ---"
# ---------------------------------------------------------------------------
F="$(make_fixture "version: 1.5.0" "$MATCHING_BLOCK")"
run_gate "$F"
[[ $GATE_RC -eq 0 ]] && pass "exits 0 when the allow key names the plugin's version" \
|| fail "should pass when key and version agree, got rc=$GATE_RC"
F="$(make_fixture 'version: "1.5.0"' "$MATCHING_BLOCK")"
run_gate "$F"
[[ $GATE_RC -eq 0 ]] && pass "exits 0 when the version is quoted" \
|| fail "a quoted version must compare the same as an unquoted one, got rc=$GATE_RC"
# ---------------------------------------------------------------------------
echo ""
echo "--- mismatched version ---"
# ---------------------------------------------------------------------------
F="$(make_fixture "version: 1.5.0" "$STALE_BLOCK")"
run_gate "$F"
[[ $GATE_RC -ne 0 ]] && pass "fails when the allow key names a different version" \
|| fail "a stale allow key must fail the push"
OUT="$(bash "$SCRIPT" "$F" 2>&1 || true)"
grep -q "kyberforge#1.5.0" <<< "$OUT" && pass "names the key that should be there" \
|| fail "the failure must state the expected key"
grep -q "kyberforge#1.4.0" <<< "$OUT" && pass "names the stale key it found instead" \
|| fail "the failure must quote back the stale key"
grep -q "Why:" <<< "$OUT" && grep -q "Fix:" <<< "$OUT" && pass "uses the FAIL/Why/Fix message block" \
|| fail "message must carry Why: and Fix: lines"
# ---------------------------------------------------------------------------
echo ""
echo "--- missing executables.allow ---"
# ---------------------------------------------------------------------------
F="$(make_fixture "version: 1.5.0" "")"
run_gate "$F"
[[ $GATE_RC -ne 0 ]] && pass "fails when there is no executables block at all" \
|| fail "a missing executables.allow must fail — apm deploys no hooks without it"
OUT="$(bash "$SCRIPT" "$F" 2>&1 || true)"
grep -q "executables:" <<< "$OUT" && grep -q "kyberforge#1.5.0" <<< "$OUT" \
&& pass "shows the block to add" || fail "the failure must show the block to add"
# An `executables:` key that is not a mapping is the same hole as no key at all.
F="$(make_fixture "version: 1.5.0" "executables:")"
run_gate "$F"
[[ $GATE_RC -ne 0 ]] && pass "fails when executables: exists but allow: does not" \
|| fail "an empty executables: block grants nothing and must fail"
# ---------------------------------------------------------------------------
echo ""
echo "--- allow present, kyberforge key missing ---"
# ---------------------------------------------------------------------------
F="$(make_fixture "version: 1.5.0" "$OTHER_PACKAGE_BLOCK")"
run_gate "$F"
[[ $GATE_RC -ne 0 ]] && pass "fails when allow: exists but names no kyberforge key" \
|| fail "an allow block covering only other packages must still fail"
# ---------------------------------------------------------------------------
echo ""
echo "--- degenerate inputs fail loudly rather than passing silently ---"
# ---------------------------------------------------------------------------
run_gate "$REPO_ROOT/definitely-not-a-directory"
[[ $GATE_RC -ne 0 ]] && pass "fails on a nonexistent REPO_ROOT" \
|| fail "a bad path must not exit 0 — that reads as 'checked, in sync'"
# No kyberforge plugin at all is the one legitimate no-op: nothing to pin.
NO_PLUGIN="$(mktemp -d)"; FIXTURES+=("$NO_PLUGIN")
echo "name: someone-else" > "$NO_PLUGIN/apm.yml"
run_gate "$NO_PLUGIN"
[[ $GATE_RC -eq 0 ]] && pass "no-ops in a repo with no kyberforge plugin" \
|| fail "a repo without plugins/kyberforge/ has nothing to check"
# ...but a kyberforge directory with no manifest is drift, not a no-op.
mkdir -p "$NO_PLUGIN/plugins/kyberforge"
run_gate "$NO_PLUGIN"
[[ $GATE_RC -ne 0 ]] && pass "fails when plugins/kyberforge/ has no apm.yml" \
|| fail "a plugin dir with no manifest must not silently pass"
F="$(make_fixture "description: no version here" "$MATCHING_BLOCK")"
run_gate "$F"
[[ $GATE_RC -ne 0 ]] && pass "fails when the plugin manifest declares no version" \
|| fail "no version means nothing to compare — must fail, not pass"
# ---------------------------------------------------------------------------
echo ""
echo "--- the fallback reader agrees with the PyYAML reader ---"
# ---------------------------------------------------------------------------
# PyYAML is deliberately not a hard dependency of this gate (no other pre-push
# hook needs it), so the shape-scan fallback carries the same verdicts. Masking
# is done with a python3 stub whose `import yaml` fails, which is the exact
# condition on a machine that has python3 without PyYAML.
NO_YAML_BIN="$(mktemp -d)"; FIXTURES+=("$NO_YAML_BIN")
printf '#!/usr/bin/env bash\nexit 1\n' > "$NO_YAML_BIN/python3"
chmod +x "$NO_YAML_BIN/python3"
run_fallback() {
GATE_RC=0
PATH="$NO_YAML_BIN:$PATH" bash "$SCRIPT" "$1" > /dev/null 2>&1 || GATE_RC=$?
}
F="$(make_fixture "version: 1.5.0" "$MATCHING_BLOCK")"
run_fallback "$F"
[[ $GATE_RC -eq 0 ]] && pass "fallback passes a matching fixture" || fail "fallback should pass when in sync"
F="$(make_fixture 'version: "1.5.0"' "$MATCHING_BLOCK")"
run_fallback "$F"
[[ $GATE_RC -eq 0 ]] && pass "fallback strips quotes from the version" || fail "fallback mishandled a quoted version"
F="$(make_fixture "version: 1.5.0" "$STALE_BLOCK")"
run_fallback "$F"
[[ $GATE_RC -ne 0 ]] && pass "fallback fails a stale key" || fail "fallback missed a stale key"
F="$(make_fixture "version: 1.5.0" "")"
run_fallback "$F"
[[ $GATE_RC -ne 0 ]] && pass "fallback fails a missing executables block" || fail "fallback missed a missing block"
F="$(make_fixture "version: 1.5.0" "$OTHER_PACKAGE_BLOCK")"
run_fallback "$F"
[[ $GATE_RC -ne 0 ]] && pass "fallback fails when no kyberforge key is present" || fail "fallback missed an absent key"
run_fallback "$REPO_ROOT"
[[ $GATE_RC -eq 0 ]] && pass "fallback passes the real repo" || fail "fallback disagrees with PyYAML on the real repo"
# ---------------------------------------------------------------------------
echo ""
echo "--- wired into the pre-push gate ---"
# ---------------------------------------------------------------------------
# A gate nobody runs is not a gate; this is the only assertion that the script
# is actually reachable from `git push`.
CONFIG="$REPO_ROOT/.pre-commit-config.yaml"
grep -q "id: check-executables-allow-sync" "$CONFIG" \
&& pass ".pre-commit-config.yaml declares the hook" || fail "hook is not declared in .pre-commit-config.yaml"
grep -q "scripts/check-executables-allow-sync.sh" "$CONFIG" \
&& pass ".pre-commit-config.yaml points at the script" || fail "hook does not reference the script path"
# ---------------------------------------------------------------------------
echo ""
echo "Results: $PASS passed, $FAIL failed"
[[ $FAIL -eq 0 ]]