docs: correct the claims this review found false
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
This commit is contained in:
@@ -98,18 +98,29 @@ governance status as `.claude-plugin/plugin.json`/`marketplace.json`:
|
||||
|
||||
## Considered options
|
||||
|
||||
**Patch `plugin.json`'s `skills`/`agents`/`commands`/`hooks` fields to point directly at `.apm/`
|
||||
paths (rejected).** Claude Code's manifest schema documents these as legitimate override fields
|
||||
that accept custom paths —
|
||||
`plugins/kyberforge/docs/research/docs/claude-code-plugins/configuration.md` shows a real example
|
||||
(`"skills": "./custom/skills/"`, `"agents": ["./custom/agents/reviewer.md"]`), so the host side of
|
||||
this would work. Rejected because apm's compiler is not a passive pass-through: `build_plugin_manifest`
|
||||
unconditionally strips these keys from every manifest it generates, on the stated assumption that
|
||||
convention directories are always host-auto-discovered and therefore never need an explicit
|
||||
pointer. Honoring this option would mean post-processing apm's compiled output on every
|
||||
`apm pack` run to re-inject fields apm actively removes — fighting a stable, intentional apm code
|
||||
path indefinitely — rather than reusing `plugin_exporter.py`'s bundle-export mapping, which already
|
||||
does the right thing and only needed its output redirected to a path the installer reads.
|
||||
**Patch `plugin.json`'s content-pointer fields to point directly at `.apm/` paths (rejected).**
|
||||
Claude Code's manifest schema documents these as legitimate override fields that accept custom
|
||||
paths — `plugins/kyberforge/docs/research/docs/claude-code-plugins/configuration.md` shows a real
|
||||
example (`"skills": "./custom/skills/"`, `"agents": ["./custom/agents/reviewer.md"]`), so the host
|
||||
side of this would work. Rejected because apm never emits such a pointer and would have to be
|
||||
worked around on every run to make it do so.
|
||||
|
||||
Be precise about the mechanism, because an earlier revision of this ADR overstated it. apm 0.28.0's
|
||||
`build_plugin_manifest` (`apm_cli/core/plugin_manifest.py`) does carry a strip loop, but its field
|
||||
list is `("agents", "skills", "commands", "instructions")` — `hooks` is **not** in it, and
|
||||
`instructions` **is**, which this ADR previously did not mention. More to the point, that loop can
|
||||
never fire: the manifest it operates on comes from `synthesize_plugin_json_from_apm_yml`
|
||||
(`apm_cli/deps/plugin_parser.py`), which only ever emits `name`, `version`, `description`,
|
||||
`author`, `license`, `homepage`, `repository` and `keywords`. The pointer fields are absent from
|
||||
apm's output because `apm.yml` has no schema for them, not because apm actively removes them — the
|
||||
`pop` loop is defensive dead code against a manifest shape apm does not produce.
|
||||
|
||||
The rejection is unaffected by that correction, only its framing. Honoring this option would still
|
||||
mean post-processing apm's compiled output on every `apm pack` run to add fields apm's schema has
|
||||
no way to express, rather than reusing `plugin_exporter.py`'s bundle-export mapping, which already
|
||||
does the right thing and only needed its output redirected to a path the installer reads. What it
|
||||
is *not* is a fight against a load-bearing apm code path — the honest statement is that apm has no
|
||||
input for these fields, and inventing one downstream is a workaround this ADR did not need.
|
||||
|
||||
**Point `marketplace.json`'s `source:` at `apm pack`'s `build/<name>-<version>/` output directly
|
||||
(rejected).** Would reuse the bundle exporter's correct mapping without adding a new script.
|
||||
@@ -121,36 +132,50 @@ source and scans directories; it does not execute a package manager's build comm
|
||||
Copying the relevant subset back to the stable `plugins/<name>/` path — where `marketplace.json`
|
||||
already points — needed no change to the marketplace source model at all.
|
||||
|
||||
## Amendment (2026-08-13): `mcpServers` is narrowly reinjected into Copilot's `plugin.json`
|
||||
## Amendment (2026-08-13, revised 2026-08-14): Copilot's `plugin.json` gets an `mcpServers` *path*
|
||||
|
||||
PR #95's review (a follow-on to this same issue #90 workstream) found a second field apm's
|
||||
compiler strips for the Copilot ecosystem: `build_plugin_manifest` unconditionally removes
|
||||
`mcpServers` from every Copilot-ecosystem `plugin.json`, its docstring stating the field is "not
|
||||
part of the Copilot plugin manifest schema." That claim is contradicted by this repo's own
|
||||
researched documentation — `plugins/kyberforge/docs/research/docs/github-copilot-plugins/
|
||||
configuration.md:49` documents `mcpServers` as a valid, optional `plugin.json` field for Copilot.
|
||||
compiler drops for the Copilot ecosystem: `build_plugin_manifest` runs
|
||||
`manifest.pop("mcpServers", None)` on every Copilot-ecosystem `plugin.json`, its docstring stating
|
||||
the field is "not part of the Copilot plugin manifest schema." That claim is contradicted by this
|
||||
repo's own researched documentation —
|
||||
`plugins/kyberforge/docs/research/docs/github-copilot-plugins/configuration.md:49` documents
|
||||
`mcpServers` as a valid, optional `plugin.json` field, typed **"string or object — MCP server
|
||||
config path or inline definitions."**
|
||||
|
||||
This is not the same situation "Considered options" above rejected. That rejection concerned
|
||||
fields apm strips *correctly*, on a stable and accurate premise: convention directories
|
||||
(`skills/`, `agents/`, `commands/`) are host-auto-discovered, so an explicit pointer is redundant
|
||||
by design. Here, apm's own stated justification for stripping `mcpServers` is factually wrong
|
||||
against documented Copilot behavior — there is no host-auto-discovery mechanism that makes an
|
||||
explicit `mcpServers` declaration redundant, the way there is for skills/agents/commands. Applying
|
||||
the same "don't fight a stable, intentional apm code path" reasoning here would mean shipping a
|
||||
plugin manifest known to be missing a field Copilot actually reads.
|
||||
This is not the same situation "Considered options" above rejected. There, apm emits no pointer
|
||||
because its schema has no input for one and the host auto-discovers the directories anyway, so
|
||||
nothing is missing. Here a field Copilot actually reads is actively removed on a premise that is
|
||||
wrong against documented Copilot behavior, and there is no auto-discovery mechanism that makes it
|
||||
redundant. Shipping the manifest as apm produces it would ship a manifest known to be incomplete.
|
||||
|
||||
Given that, `scripts/sync-plugin-content.sh`'s `reinject_mcp_servers()`, called from `sync_one()`,
|
||||
narrowly re-injects `mcpServers` into `.github/plugin/plugin.json` after `apm pack` runs, sourced
|
||||
from the plugin's own `.mcp.json`, and only when it declares at least one server — matching apm's
|
||||
own Claude-ecosystem builder, which omits the field entirely rather than emitting
|
||||
`mcpServers: {}`. **Both modes re-inject**, not just real syncs: real mode writes into the plugin
|
||||
root directly, `--check` into its throwaway copy first, so the manifest diff compares against the
|
||||
same content a real sync would actually produce (see the script's own header). A check-mode
|
||||
re-injection is what keeps `--check` from reporting permanent phantom drift on every plugin that
|
||||
ships an `.mcp.json`. This is scoped to one field found to be incorrectly stripped, not a
|
||||
reversal of the broader position above: the rejection of patching
|
||||
`skills`/`agents`/`commands`/`hooks` pointers still holds, since apm's premise for stripping those
|
||||
remains accurate.
|
||||
`scripts/sync-plugin-content.sh`'s `reinject_mcp_servers()`, called from `sync_one()`, therefore
|
||||
sets `mcpServers` on `.github/plugin/plugin.json` after `apm pack` runs — to the **string
|
||||
`".mcp.json"`**, the path form of the documented type, not the resolved server objects. Only when
|
||||
the plugin's `.mcp.json` declares at least one server, matching apm's own Claude-ecosystem builder,
|
||||
which omits the field entirely rather than emitting `mcpServers: {}`.
|
||||
|
||||
**The payload is a path because an inlined object is a credential-leak path.** The original
|
||||
implementation copied `.mcp.json`'s resolved `mcpServers` object into the manifest with `jq`. That
|
||||
route bypasses apm's own `_sanitize_mcp_servers()` (`apm_cli/core/plugin_manifest.py`), which
|
||||
strips credential keys and redacts secret values out of `.mcp.json` precisely because — in its own
|
||||
words — "copying them verbatim into a committed `plugin.json` would exfiltrate them into the
|
||||
distributed artefact." Today's `.mcp.json` files here carry no `env` block, so nothing leaked; the
|
||||
first one that did would have written a live token into a tracked, published manifest, with the
|
||||
sanitizer sitting one code path away and never invoked. A path reference cannot carry a secret at
|
||||
all: the manifest names a file, and resolution happens in the host at load time. This also matches
|
||||
apm's documented posture for MCP secrets — `microsoft-apm/configuration.md:96-98` requires `${VAR}`
|
||||
indirection so secrets are "never committed to the manifest."
|
||||
|
||||
**Both modes re-inject**, not just real syncs: real mode writes into the plugin root directly,
|
||||
`--check` into its throwaway copy first, so the manifest diff compares against the same content a
|
||||
real sync would actually produce (see the script's own header). A check-mode re-injection is what
|
||||
keeps `--check` from reporting permanent phantom drift on every plugin that ships an `.mcp.json`.
|
||||
|
||||
This remains scoped to one field found to be incorrectly dropped. It does not reopen the
|
||||
content-pointer option rejected above: those fields stay absent because apm has no schema input for
|
||||
them and the host needs no pointer, which is a different situation from a documented field being
|
||||
actively removed.
|
||||
|
||||
Consequence: if a future apm release corrects the Copilot `mcpServers` omission, `reinject_mcp_servers()`
|
||||
and its call site become dead code and should be deleted — nothing else in this ADR depends on the
|
||||
@@ -166,13 +191,15 @@ A function name is stable enough to grep for; a line number in an ADR is stale b
|
||||
As originally executed, `sync-plugin-content.sh` wrote the merged hooks file to
|
||||
`plugins/<name>/hooks.json`. That path is scanned by nothing. Claude Code convention-scans
|
||||
`hooks/hooks.json`, and the "Plugin Directory Layout" table this ADR's own root-cause analysis
|
||||
quotes above says so on the same line it says "All content directories must be at the plugin root,
|
||||
not inside `.claude-plugin/`"
|
||||
(`plugins/kyberforge/docs/research/docs/claude-code-plugins/configuration.md:100`). The
|
||||
implementation read "at the plugin root" and dropped the file there; the row it was reading names
|
||||
`hooks/hooks.json`. So this ADR shipped with the contract quoted correctly in its diagnosis and
|
||||
violated in its output — the flat mirror bridged skills and agents into discovery and left hooks
|
||||
exactly as undiscoverable as before the fix.
|
||||
quotes above says so:
|
||||
`plugins/kyberforge/docs/research/docs/claude-code-plugins/configuration.md:100` is the row naming
|
||||
`hooks/hooks.json`, six lines below the table's preamble at `:94` — "All content directories must
|
||||
be at the plugin root, not inside `.claude-plugin/`". The two are not the same line; an earlier
|
||||
revision of this amendment said they were. The implementation read the preamble's "at the plugin
|
||||
root" and dropped the file there, without reading the row that names the path. So this ADR shipped
|
||||
with the contract quoted correctly in its diagnosis and violated in its output — the flat mirror
|
||||
bridged skills and agents into discovery and left hooks exactly as undiscoverable as before the
|
||||
fix.
|
||||
|
||||
The merged file therefore moves to `plugins/<name>/hooks/hooks.json`. A root-level `hooks.json`
|
||||
left over from a prior sync is stale output: a real sync deletes it, `--check` reports it as
|
||||
@@ -184,9 +211,11 @@ only those two grow a mirrored hooks file at all.
|
||||
This does **not** reopen the "patch `plugin.json` pointer fields" option rejected above. The move
|
||||
needs no `hooks` pointer in `plugin.json`: `hooks/hooks.json` *is* the convention path, so the
|
||||
host finds it by auto-discovery, exactly as it finds `skills/` and `agents/`. The rejection stands
|
||||
for the reason it was made — apm's `build_plugin_manifest` strips pointer fields unconditionally
|
||||
and is right to, because convention directories need no pointer. Writing to the convention path is
|
||||
what makes that premise true here rather than something to fight.
|
||||
for the reason it was made, once stated accurately — apm emits no pointer field for any of these,
|
||||
because `apm.yml` has no key that produces one, and none is needed when content sits at the
|
||||
convention path. (`hooks` was never in `build_plugin_manifest`'s strip list at all; see the
|
||||
corrected mechanism note under "Considered options".) Writing to the convention path is what makes
|
||||
the no-pointer premise true here rather than something to work around.
|
||||
|
||||
## Consequences
|
||||
|
||||
|
||||
Reference in New Issue
Block a user