AGENTS.md told an offline agent to push with SKIP=apm-marketplace-check and
asserted that hook was "the only one whose failure mode is 'no network'".
Running all 12 pre-push hooks under a network namespace shows two fail, for
one shared cause: apm-pack-check-clean resolves the same remote entry. An
exact pin does not remove the ls-remote, so both hooks are named now.
AGENTS.md also said everything in a plugin root except .apm/ is generated.
Plugin roots carry hand-authored README.md, docs/, bin/, sources.md and
.mcp.json, so an agent would hunt for an .apm/ source that does not exist or
refuse the edit. The rule is positional: immunity belongs to the plugin root,
and anything inside a mirrored directory is still rm -rf'd.
ADR-0017 said apm strips a hooks field. The real loop is (agents, skills,
commands, instructions) -- hooks absent, instructions never mentioned -- and
it can never fire, because synthesize_plugin_json_from_apm_yml only emits the
eight identity fields. The decision stands; the mechanism was overstated. Its
mcpServers amendment is rewritten for the pointer payload and now records the
real reason: inlining bypassed apm's credential sanitizer.
ADR-0015's owner.email and version-pin passages are corrected against the apm
source, and ADR-0016 gains the disallowedTools amendment. agent-audit's
allowlist is data, so it gains disallowedTools too -- the ADR and the
validator that enforces it had come apart.
architecture.md described a root CLAUDE.md that imports two files (it imports
one, plus an RTK block) and pointed at an ADR index that does not exist.
Seven skill READMEs listed tests/ files the mirror strips, promising installed
users files their install lacks; those rows are marked source-only, with the
depth-4 template tests explicitly called out as surviving. And
plugins/kyberforge/hooks/README.md, deleted during the conversion and
preserved nowhere, is restored to a path the mirror does not own -- verified
by running a sync against a scratch copy.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01X7GvKuJfy2WrdBmUttV4DT
The mirror's `tests/` exclusion was depth-agnostic, so it deleted
`skill-author/assets/templates/tests/` — a template the skill scaffolds FROM —
alongside the depth-2 dev fixtures it was meant to drop. Since ADR-0017 makes the
mirror the installed content, the shipped scaffolder was broken: the mirror copy of
`new-skill.sh` exited 2 on `sed: can't read .../tests/README.md`, leaving a
half-written skill, while the byte-identical `.apm/` copy exited 0. `--check` was
green about it.
Check mode was restructured rather than patched because `diff -x` matches a basename
at any depth and cannot express the depth-2 scoping the fix needs — the two modes
could not be made to agree by construction. Check mode now runs the real `sync_dir`
into a throwaway root and diffs with no exclusions, leaving the exclusion rule and
the hooks destination each in exactly one place.
Also fixed here, all previously invisible to `--check`:
- Merged hooks were written to `<plugin>/hooks.json`, which Claude Code does not
convention-scan, while ADR-0017 itself quoted `hooks/hooks.json` as the contract.
Moved, with the legacy path cleaned up as stale. No `hooks` pointer is added to
`plugin.json`, so this does not reopen the option ADR-0017 rejected.
- Only the first drift per plugin was reported: `diff | sed` returns 1 under
`pipefail`, and `set -e` killed the subshell before the remaining checks and
before `FAIL=1`.
- File-mode and symlink drift were invisible, so `--check` and a real sync
disagreed; a find-based type/mode manifest now covers both.
The tests pinned almost none of this — the stale-skill wipe, the check-mode stale
branch, three `MIRROR_DIRS` entries and the hooks newline normalization could each
be deleted with the suite still green. All are now mutation-tested.
Refs: #90
ADR: 0017
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01X7GvKuJfy2WrdBmUttV4DT
Running ADR-0017's cited live behavioral test literally from this
repo's root gives a contaminated signal: this repo's own project-level
.claude/settings.json enables all 6 holocron plugins, so Claude Code
loads all of them rather than isolating kyberforge's discoverability.
Documents the neutral-cwd + absolute --plugin-dir reproduction needed
to isolate the signal.
PR #95's review of the issue #90 apm-conversion work found several
defects in scripts/sync-plugin-content.sh and the gate wired to it:
- --check claimed never to mutate the plugin root, but apm pack still
wrote .claude-plugin/plugin.json and .github/plugin/plugin.json into
the real plugin_dir on first-time creation. --check now packs a
throwaway copy instead.
- check-plugin-content-sync hardcoded the six plugin directories
instead of deriving them the way check-manifests.sh already does.
Added an --all flag that parses .claude-plugin/marketplace.json, and
simplified the pre-commit hook to use it.
- A missing plugin_dir and one that legitimately has no .apm/ yet both
reported SKIP/success; a missing directory now FAILs.
- The dispatch loop backgrounded every plugin with no concurrency cap,
unlike the JOBS-bounded pattern this same PR added to
tests/run-bats.sh and tests/run-tests.sh. Added the same
bash-3.2-safe getconf + batched-wait cap here for consistency.
- Per-plugin scratch/log/status files were keyed only by basename, with
no collision guard across arguments; added a fail-fast check.
- sync_hooks_json()'s trailing-newline normalization was duplicated
between its --check and write branches; factored into one helper.
- tests/test-sync-plugin-content.sh set two competing `trap ... EXIT`
statements, so the first (cleaning up $FIXTURE) was silently
replaced by the second and its tmp dir leaked every run. Adopted the
track()/CLEANUP_DIRS pattern already used in
tests/test-check-release-needed.sh.
Separately: apm's Copilot-ecosystem plugin.json builder unconditionally
strips mcpServers, citing (in its own docstring) that the field is out
of schema for Copilot -- a claim this repo's own researched Copilot
plugin schema docs contradict. reinject_mcp_servers() narrowly restores
it from the plugin's .mcp.json on real syncs only, regenerating
plugins/bin/.github/plugin/plugin.json (the only plugin that currently
declares any MCP servers). Documented as an amendment to ADR-0017,
since it's a deliberate, narrow exception to that ADR's rejection of
patching apm's compiled output -- apm's premise for stripping
skills/agents/commands/hooks pointers is still accurate; its premise
for stripping mcpServers is not.
All 12 assertions in tests/test-sync-plugin-content.sh pass
individually, plus 5 new regression tests added for this round; the
full bats and shell-script suites are green; shellcheck is clean.
Refs: #95
ADR: 0017
Claude Code's (and Copilot's) native plugin installer has zero awareness of
.apm/ nesting -- it convention-scans only flat skills/, agents/, commands/,
hooks.json at each plugin's root. Confirmed via strings on the installed
claude binary and live installs of git@holocron/gitea@holocron/kyberforge@
holocron, all reporting Skills(0) Agents(0) Hooks(0) post ADR-0015's apm
conversion. Root cause (apm_cli/core/plugin_manifest.py): apm's plugin.json
compiler deliberately strips skills/agents/commands keys, assuming the host
already auto-discovers those convention directories -- it has no model of
.apm/ being host-visible at all. Separately, apm's own bundle exporter
(apm_cli/bundle/plugin_exporter.py, behind `apm pack --format plugin`)
implements the correct .apm/ -> flat mapping, but only ever targeted
build/<name>-<version>/, a path nothing in marketplace.json's source: points
at.
scripts/sync-plugin-content.sh wraps that bundle exporter and copies its
agents/, skills/, commands/, instructions/, extensions/, and merged
hooks.json back into each plugin's own root as a second tracked
compiled-output category -- same governance status as
.claude-plugin/plugin.json: generated from .apm/, never hand-edited. tests/
subdirectories are excluded from the mirror (dev fixtures, not host-visible
runtime content; several hardcode a relative repo-root walk-up sized for the
.apm/-nested depth, which breaks when duplicated one level shallower).
Applied for real across all 6 plugins and verified two ways: `claude plugin
validate --strict` passes on every real plugin directory, and a live
`claude --plugin-dir <path> -p "list skills/agents"` behavioral test
confirms content is now actually discovered.
Also, from the same issue #90 review round:
- scripts/check-manifests.sh pointed at each plugin's root-level plugin.json
(checking skills/hooks/mcpServers/agents pointer fields) -- that file was a
stale near-duplicate of .claude-plugin/plugin.json nothing else read or
wrote, now deleted across all 6 plugins. check-manifests.sh is rewritten to
validate .claude-plugin/plugin.json instead, and drops the pointer-field
checks entirely (nothing to check -- those fields are correctly absent by
design). Content-presence drift is now check-plugin-content-sync's job, a
new pre-push hook wired in .pre-commit-config.yaml.
docs/adr/0017 records the root cause and decision in full, including two
rejected alternatives (patching plugin.json's path fields directly -- apm's
compiler strips them on every run; pointing marketplace.json at apm pack's
build/ output -- a version-suffixed non-source directory nothing can install
from without an extra build step). ADR-0015 and CONTEXT.md are updated to
point at it.
Refs: #90