Commit Graph

11 Commits

Author SHA1 Message Date
cf625229f7 fix(scripts): detect the .apm/ content the mirror loses silently
apm's bundle exporter drops symlinks entirely, so a symlink under .apm/ never
reaches the mirror -- and no gate could see it, because every existing check
diffs the live mirror against a bundle-derived copy and both sides lack the
file. It is an absence with nothing left to mismatch against, the only class of
.apm/ content that vanishes without a trace. check_apm_symlinks reads the .apm/
source tree, where the loss is visible, and fails both modes. Reported rather
than resolved: dereferencing would make a real sync emit content the bundle does
not contain, which is the reimplementation ADR-0017 rejects.

--check --all could also pass having verified fewer plugins than the marketplace
lists: a plugin whose .apm/ had gone was SKIPped rather than counted, and the
earlier floor only caught zero. The count is now checked against the marketplace's
own local-package list. There is no exempt state -- ADR-0015 makes .apm/ the sole
authoring source for every local plugin, so a listed plugin without one is drift.

On the Copilot hooks gap, the decision is to document, not implement. Copilot
declares no hooks path and apm emits none, which looks like the mcpServers case
-- but that exception holds because .mcp.json is one host-agnostic format both
ecosystems read, so a pointer to it is true whatever it contains. Hooks have no
shared format: Claude expects nested matcher groups under PascalCase events,
Copilot requires version: 1, camelCase, and a bash/powershell split. apm merges
.apm/hooks/*.json into exactly one file, at Claude's convention path. A pointer
would assert a Claude-shaped file is Copilot-shaped -- an incomplete manifest
traded for a wrong one -- and it is not inert today either, since {"hooks": {}}
lacks Copilot's mandatory version key. A test pins the decision, so restoring the
pointer fails until someone confronts the schema mismatch.

Tests: 77 -> 92 assertions.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01X7GvKuJfy2WrdBmUttV4DT
2026-08-14 14:20:47 +00:00
a700b3771c fix(scripts): make the mirror's mode check umask-independent
The previous round widened path_manifest from the exec bit to full permission
bits, and that made check-plugin-content-sync fail at pre-push on a pristine
tree. hooks/hooks.json is not copied from the bundle -- sync_hooks_json writes
it with printf, i.e. at the runtime umask -- while the real side comes from the
checkout. On a umask-002 clone the two disagree, 664 vs 644, and no commit can
reconcile them because git tracks no non-exec mode.

The rule adopted: record a mode for a path this pipeline copies, never for one
it writes. A copied path's mode traces to the same checkout on both sides, so
comparing it means something; a written path's mode is the writer's umask on
one side and the checkout's on the other, which are independent. That is the
same rationale the directory exclusion already carried -- what broke was the
premise that files are immune. Normalising instead was rejected: pinning the
generated side cannot fix a checked-out side that is already 664.

The unconditional chmod 644 in reinject_mcp_servers goes for the same reason;
writing through the destination inode already closed the original 0600 bug.

The mode coverage added for the two plugin.json manifests is removed rather
than documented, because it measured nothing on any axis. In check mode the
expected side is a cp -a of the real plugin root, so apm rewrites an existing
inode and inherits its mode; and a symlinked manifest is copied as a symlink
and written straight through, so both sides agreed no matter what. That
symlink case is a real hazard -- the re-injection corrupts the link's target --
so it is now asserted directly instead.

Also: an unparseable or non-object per-plugin plugin.json killed the manifest
walk mid-loop; the source-less-entry guard closed only source: null and let
every other malformed value through; the select it backstops was extracted so
a test can exercise it independently, which nothing could before; and two more
`|| pwd` fallbacks now hard-error -- with a decoy marketplace.json in $PWD,
--all derived its plugin list from it.

Tests: 63 -> 77 and 23 -> 31 assertions.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01X7GvKuJfy2WrdBmUttV4DT
2026-08-14 12:29:55 +00:00
9e612fd183 fix(scripts): re-inject .mcp.json as a pointer, not resolved content
reinject_mcp_servers copied .mcp.json's mcpServers into the compiled Copilot
manifest verbatim via jq. apm's own path does not: collect_mcp_servers runs
_sanitize_mcp_servers(), which drops env/headers/authorization and redacts
secret-shaped keys, because copying them into a committed manifest exfiltrates
them into the distributed artefact. The re-injection was the only route around
that sanitizer, and it wrote to a tracked, marketplace-distributed file.

Both host schemas document mcpServers as "string or object -- config path or
inline definitions", so the pointer form is valid and carries no resolved
content. It also preserves the ${VAR} indirection the sanitizer strips.

Also in this pass:
- mktemp+mv left the manifest at 0600 while --check compared content only, so
  a real sync silently demoted a mode the gate could not see
- --check --all exited 0 when the marketplace yielded zero plugins, including
  on unparseable JSON: the one gate whose work list comes from a generated file
  could be silenced by regenerating its own input
- sync_dir took an unguarded $target_dir despite a comment claiming otherwise
- basename '.'/'..' escaped $SCRATCH_ROOT and made bundle selection arbitrary
- path_manifest compared only the exec bit, so check and sync disagreed
- sync-marketplace-mirror.sh fell back to pwd outside a worktree and reported
  no drift on a tree it never identified

Mode comparison is deliberately files-only: directory modes come from umask on
one side and checkout on the other and git tracks neither, so comparing them
reports the runner's umask rather than a property of the mirror.

Tests: 44 -> 67 and 15 -> 19 assertions, each verified to fail under the
mutation it exists to catch.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01X7GvKuJfy2WrdBmUttV4DT
2026-08-14 11:03:45 +00:00
5a61b417c9 fix(scripts): bring the generated hooks/ directory under the mirror's ownership
`checked_paths` covered hooks/hooks.json but not the hooks/ directory
holding it, so a stray file dropped inside, or an empty hooks/ left
behind once .apm/hooks/ stopped producing anything, was invisible to
--check. Check and sync agreed in both cases, so the invariant held --
but a stray in a directory the mirror owns should be drift, exactly as
it is inside skills/ or agents/. A stray at the PLUGIN root stays out
of scope by design: README.md, docs/, bin/, .mcp.json are hand-authored.

hooks/ is now wiped and rebuilt like every MIRROR_DIRS destination, and
the directory is listed in checked_paths so the recursive manifest sees
one-sided entries.

Issue #97 item 4 reports `prompts` as documented-but-unmirrored. That is
refuted: MIRROR_DIRS lists DESTINATION directories, and apm folds
.apm/prompts/ into commands/ (renaming *.prompt.md to *.md), verified
empirically. A plugin adding .apm/prompts/ is mirrored today; adding a
`prompts` entry would name an output directory apm never emits. Pinned
with a characterization test that fires if that mapping ever changes,
plus a comment so it is not refiled.

Guards the new wipe with ${target_dir:?}: `set -u` aborts on an unset
variable but not an empty one, which would make it `rm -rf /hooks`.

Refs #97

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01X7GvKuJfy2WrdBmUttV4DT
2026-08-14 08:03:42 +00:00
d4fa4b7153 fix(kyberforge): stop the content mirror amputating a shipped template asset
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
2026-08-14 01:52:37 +00:00
fd70c8d65e fix(kyberforge): catch plugin.json and hooks.json drift in sync-plugin-content.sh --check
--check's throwaway pack copy seeded .claude-plugin/plugin.json and
.github/plugin/plugin.json from the real plugin dir, then packed without
--force -- apm pack silently skips regenerating a plugin.json that already
exists, so the diff always compared the copy against itself and never caught
drift in the compiled name/version/description/mcpServers. --force is now
always passed; in check mode it forces regeneration inside the throwaway copy
only, which sync_plugin_manifest() then diffs against the real committed
manifest.

sync_hooks_json() returned early whenever .apm/hooks/ was missing, without
checking whether a stale hooks.json was still sitting at the plugin root from
a prior sync -- unlike sync_dir(), which already detects that kind of orphaned
mirrored output. It now mirrors sync_dir()'s shape: flagged as drift in
--check, removed on a real sync.

Running the corrected --check --all against this repo's own plugins surfaced
3 real orphans: plugins/{git,gitea,core}/hooks.json, empty stubs added in
4edaaac only to satisfy an old plugin.json pointer-field check that no longer
exists (their compiled plugin.json has never had a hooks field, and none of
the three ever had .apm/hooks/). Removed as part of this fix since they're
exactly the drift the corrected check now catches -- leaving them would break
the sync-plugin-content pre-push gate on this branch.

Also extracts two shared helpers into scripts/lib/, sourced by this script and
others so a future bug fix doesn't need hand-applying three times:
- marketplace-plugins.sh: walks marketplace.json for local plugin dirs (this
  script's --all branch and check-manifests.sh had near-identical copies)
- batch-run.sh: the bounded-batch concurrent job runner (this script,
  tests/run-tests.sh, and tests/run-bats.sh each hand-rolled the same
  core-count-capped wait loop independently)

Extended tests/test-sync-plugin-content.sh with coverage for both drift cases
(plugin.json version-bump drift, orphaned-hooks.json drift), including that a
re-sync clears each.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01X7GvKuJfy2WrdBmUttV4DT
2026-08-13 22:12:04 +00:00
b0b1470f2c fix(kyberforge): guard unguarded array expansions in sync-plugin-content.sh
force_flag and plugin_dirs expanded unguarded under set -euo pipefail,
tripping "unbound variable" on bash 3.2 (macOS) whenever the array is
empty -- which is the default --check invocation wired into the
pre-commit drift gate. Apply the same ${arr[@]+"${arr[@]}"} guard
already used for seen_names in this file.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01X7GvKuJfy2WrdBmUttV4DT
2026-08-13 21:29:18 +00:00
9c140efa2e fix(kyberforge): harden plugin-content sync, reinject Copilot mcpServers
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
2026-08-13 19:47:36 +00:00
c3a56d89f0 perf(kyberforge): scope skill-frontmatter to .apm/ and parallelize plugin sync
skill-frontmatter's files: pattern matched any SKILL.md, so it ran twice per
sync -- once on the .apm/ source and again on the compiled flat-mirror copy.
Scoped it to .apm/skills/ like its sibling hooks (skill-size-check,
vale-audit-prefilter-skill), which already only check the source.

sync-plugin-content.sh ran `apm pack` once per plugin, serially -- each
invocation is dominated by fixed CLI startup cost rather than per-plugin
work, so 6 plugins paid that cost 6 times over (~3.1s). Backgrounds the
per-plugin work instead, buffering each plugin's output so concurrent
DRIFT/FAIL messages can't interleave, then flushes in stable order after
`wait` (~1.3s, confirmed idempotent on a real sync).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01X7GvKuJfy2WrdBmUttV4DT
2026-08-13 18:23:47 +00:00
b0936ad386 fix(kyberforge): force plugin.json regeneration on every real sync
apm pack silently skips regenerating plugin.json when it already exists
("already exists; skipping plugin.json generation. Re-run with --force to
overwrite it."). sync-plugin-content.sh never passed --force, so
.claude-plugin/plugin.json and .github/plugin/plugin.json were written
once on first sync and never refreshed again -- a name/version/description
edit in a plugin's apm.yml would silently stop propagating, with no gate
catching it (check-manifests.sh only checks presence; check-plugin-content-
sync explicitly excludes plugin.json; apm-pack-check-clean is scoped to
marketplace.json only).

Pass --force on real-mode syncs only (--check must not mutate the plugin
root, so plugin.json staleness there stays a known, undetected gap until
the next real sync -- documented in the script's header comment).

Regenerating surfaced a second, unrelated bug: apm's writer and
pretty-format-json's --autofix disagree on JSON key order (semantic vs.
alphabetical), so every real sync would otherwise flip plugin.json's key
order right back after a commit re-alphabetized it. Excluded the four
apm-generated manifests (plugin.json x2, marketplace.json x2) from
pretty-format-json -- their exact formatting is dictated by apm's own
writer as compiled output, not this repo's generic JSON prettifier.

Verified idempotent: a second real sync after this fix produces zero
further diff.

Refs: #90
2026-08-13 18:05:29 +00:00
38f1ba4e03 fix(kyberforge): bridge apm content to Claude Code's flat plugin discovery
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
2026-08-13 16:59:03 +00:00