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:
@@ -124,12 +124,13 @@ correction) sorted what they document into three buckets:
|
||||
adaptation — closed, merged in #93) and #90 (the actual repo conversion, which also deleted
|
||||
`plugin-author`/`marketplace-author` — tracked through to merge; treat #90's own state as the
|
||||
authority on whether it has landed, not this line).
|
||||
- **`displayName` is gone from all six compiled `plugin.json` files, and `owner.email` from the
|
||||
marketplace manifest — accepted, not overlooked.** `apm.yml` has no key that compiles to either,
|
||||
so the conversion dropped both: every `plugins/<name>/.claude-plugin/plugin.json` now carries
|
||||
- **`displayName` is gone from all six compiled `plugin.json` files — accepted, not overlooked.**
|
||||
`apm.yml` has no key that compiles to it: `synthesize_plugin_json_from_apm_yml`
|
||||
(`apm_cli/deps/plugin_parser.py`) emits only `name`, `version`, `description`, `author`,
|
||||
`license`, `homepage`, `repository` and `keywords`, and nothing in `plugin_manifest.py` adds
|
||||
`displayName` afterwards. So every `plugins/<name>/.claude-plugin/plugin.json` now carries
|
||||
`author`/`description`/`homepage`/`keywords`/`license`/`name`/`repository`/`version` (plus
|
||||
`mcpServers` for `bin`) and no `displayName`, and `.claude-plugin/marketplace.json`'s `owner`
|
||||
block is `{name, url}` only. Both fields are optional —
|
||||
`mcpServers` for `bin`) and no `displayName`. The field is optional —
|
||||
`plugins/kyberforge/docs/research/docs/claude-code-plugins/api-reference.md:14` lists
|
||||
`displayName` as `Required: No`, "Human-readable name shown in plugin manager" — which is why
|
||||
`claude plugin validate --strict` still passes on all six. The visible cost is that the plugin
|
||||
@@ -138,16 +139,31 @@ correction) sorted what they document into three buckets:
|
||||
`reinject_*` workaround of the kind ADR-0017's amendment reserves for fields apm strips on a
|
||||
factually wrong premise, and apm's premise here is simply that the key does not exist in its
|
||||
schema.
|
||||
- **`mattpocock-skills` is now version-pinned, and the pin is maintained by hand.** Pre-conversion
|
||||
the entry was `{"repo": "mattpocock/skills", "source": "github"}` — an unpinned reference that
|
||||
tracked the upstream default branch, so consumers got whatever was on it at install time. Root
|
||||
`apm.yml` now declares `version: "^1.2.0"` for it, which `apm pack` resolves and freezes into
|
||||
`.claude-plugin/marketplace.json` as `ref: v1.2.3` + an explicit `sha`. Consumers get a
|
||||
reproducible version instead of a moving target, which is the improvement; the cost is that
|
||||
nothing advances it. apm has no version-bump automation (established under "Versioning" in issue
|
||||
#90's plan), so picking up a new upstream release means a human editing the `version:` range in
|
||||
root `apm.yml` and re-running `apm pack`. Left un-bumped, the marketplace pins an ageing release
|
||||
indefinitely and silently.
|
||||
- **`owner.email` was dropped by mistake and has been restored (2026-08-14).** An earlier revision
|
||||
of this ADR listed `owner.email` alongside `displayName` as a field `apm.yml` "has no key that
|
||||
compiles to." That was wrong. `apm_cli/marketplace/yml_schema.py:186` defines
|
||||
`_AUTHOR_OBJECT_KEYS = frozenset({"name", "email", "url"})`, and an `email:` under root
|
||||
`apm.yml`'s `marketplace.owner` block was empirically confirmed to compile straight through into
|
||||
`.claude-plugin/marketplace.json`'s `owner`. The key is declared in root `apm.yml` again and the
|
||||
compiled `owner` block is `{name, email, url}`. Only `displayName` is a genuine schema gap; this
|
||||
one was a documentation error that removed working configuration.
|
||||
- **`mattpocock-skills` is pinned to an exact version, and the pin is advanced by hand.**
|
||||
Pre-conversion the entry was `{"repo": "mattpocock/skills", "source": "github"}` — an unpinned
|
||||
reference that tracked the upstream default branch, so consumers got whatever was on it at
|
||||
install time. The conversion first replaced that with `version: "^1.2.0"`, which was still not a
|
||||
pin: a caret range has nothing to freeze it, because there is no lockfile for
|
||||
`marketplace.packages[]`. `apm pack` re-resolved the range against upstream on **every** run, so
|
||||
an upstream `v1.2.4` would immediately invalidate the committed `ref`/`sha` and fail
|
||||
`apm-pack-check-clean` with exit 4 — blocking every push in the repo, triggered by a third party
|
||||
at an unrelated moment, with no local change to explain it. Root `apm.yml` therefore declares an
|
||||
exact `version: "1.2.3"`, which `apm pack` freezes into `.claude-plugin/marketplace.json` as
|
||||
`ref: v1.2.3` + an explicit `sha`. Two consequences, both intended: the committed ref/sha is
|
||||
genuinely reproducible and cannot move under the repo, and picking up a new upstream release is a
|
||||
deliberate act — a human edits the `version:` string in root `apm.yml` and re-runs `apm pack`.
|
||||
apm has no version-bump automation (established under "Versioning" in issue #90's plan), so an
|
||||
ageing pin is the accepted cost of a push gate that only fires on this repo's own changes.
|
||||
Note the pin does not make the entry offline-resolvable: an exact version still requires a
|
||||
`git ls-remote`, which is why two pre-push hooks need the network (see `AGENTS.md`).
|
||||
- **Caveat on "Status: executed" above:** issue #90's own execution comment flagged, before merge,
|
||||
that Claude Code's ability to actually load content out of `.apm/` was unverified — that caveat
|
||||
turned out to be a real defect, not a formality: the native installer has zero awareness of
|
||||
|
||||
@@ -36,6 +36,9 @@ At **plugin scope only** (destination package has an `apm.yml` at its root — a
|
||||
package compiled via `apm compile`), `.apm/agents/<name>.agent.md` carries only `name`,
|
||||
`description`, `model`, and the prose body. No `tools:` field, no Claude-only fields, at all.
|
||||
|
||||
*(Narrowed by the 2026-08-14 amendment below: `disallowedTools` is admitted as a fifth allowed
|
||||
field. `tools:` and every other Claude-only knob remain excluded on the reasoning given here.)*
|
||||
|
||||
Absent `tools:` means inherit-all-tools on both harnesses — the one value that is never wrong
|
||||
on either target, unlike a present, harness-specific value that is guaranteed wrong on at least
|
||||
one of them.
|
||||
@@ -70,11 +73,48 @@ harness. Tracking the breakage doesn't prevent it, and the chosen decision alrea
|
||||
equivalent visibility (a SUGGESTION finding) without ever shipping the wrong value in the first
|
||||
place.
|
||||
|
||||
## Amendment (2026-08-14): the write fence comes back as a denylist
|
||||
|
||||
The decision above generalised from `tools:` to "no tool restriction at all". That over-reached.
|
||||
The unportability argument is specific to the **allowlist**: Claude Code reads `tools:` as a
|
||||
delimited string of its own tool names, Copilot CLI reads it as a list drawn from its
|
||||
alias vocabulary (`execute`/`read`/`edit`/`search`/`agent`/`web`), so one value is wrong on one
|
||||
harness. That reasoning stands, and `tools:` stays out of every plugin-scope agent.
|
||||
|
||||
A **denylist** has no such conflict.
|
||||
`plugins/kyberforge/docs/research/docs/claude-code-plugins/agent-definition.md:39` documents
|
||||
`disallowedTools` as a "Denylist applied before `tools`… Takes precedence over `tools`", and — the
|
||||
part that matters here — it is **not** in that document's plugin-subagent ignore list. Line 99
|
||||
names exactly three fields plugin agents silently ignore: `hooks`, `mcpServers`, `permissionMode`.
|
||||
`disallowedTools` is absent from that list, so it is honoured for plugin agents. On the Copilot
|
||||
side, the same verbatim-copy behaviour that makes `tools:` dangerous makes `disallowedTools`
|
||||
harmless: an unrecognised frontmatter key is inert, and unlike a wrong `tools:` value it cannot
|
||||
grant or misroute anything — the worst case is that the fence is absent on that harness, which is
|
||||
the status quo the decision above already accepted.
|
||||
|
||||
So the read-only orchestrator agents regain their write fence: `gitea-orchestrate`,
|
||||
`apm-orchestrate` and `lint-runner` each carry `disallowedTools: Edit, Write, NotebookEdit` plus
|
||||
explicit prose in the body stating the agent does not edit files. `git-orchestrate` is deliberately
|
||||
excluded — it legitimately declared `edit` before the conversion and still needs to write.
|
||||
|
||||
Net position: the allowlist stays dropped for the reason originally given; the specific capability
|
||||
that mattered (preventing a read-only orchestrator from writing) is restored by the one mechanism
|
||||
that is portable by construction. The consequence below is narrowed accordingly.
|
||||
|
||||
Enforcement follows the decision: `agent-audit`'s plugin-scope validator reads its allowlist as
|
||||
data from the `apm-agent-allowlist` section of
|
||||
`plugins/kyberforge/.apm/skills/agent-audit/references/field-inventory.md`, and that line now reads
|
||||
`name description model source_keys disallowedTools`. `disallowedTools` also stays in that file's
|
||||
`claude-code-only-fields` list, which is not a contradiction — that list governs whether a field
|
||||
may cross the CC/Copilot boundary in a real project/user-scope *pair*, a different question from
|
||||
whether a field is safe under verbatim copy in a single vendor-neutral file.
|
||||
|
||||
## Consequences
|
||||
|
||||
- Every plugin-scope APM agent loses per-agent tool restriction and any Claude-only capability
|
||||
- Every plugin-scope APM agent loses per-agent tool *allowlisting* and any Claude-only capability
|
||||
(isolation, maxTurns, effort, memory, permissionMode) until APM ships a real per-target
|
||||
integrator for the agent primitive. This is a known, accepted regression, not an oversight.
|
||||
Tool **denial** is not part of that loss — see the 2026-08-14 amendment above.
|
||||
- **ADR-0005 is partially superseded** — its plugin-scope clause ("directory containing
|
||||
`plugin.json` is plugin scope → both files land in `<root>/agents/`") no longer applies.
|
||||
Plugin scope is now "directory containing `apm.yml` → single vendor-neutral file lands 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
|
||||
|
||||
|
||||
@@ -32,7 +32,9 @@ Two compilers produce the plugin roots you see in the tree:
|
||||
- **`apm pack` compiles the manifests** (ADR-0015). Per plugin: `.claude-plugin/plugin.json` and `.github/plugin/plugin.json`, both generated from `plugins/<name>/apm.yml`. Repo-wide, from the root `apm.yml`'s `marketplace:` block: `.claude-plugin/marketplace.json` (apm's `claude` output profile) and `.agents/plugins/marketplace.json` (its `codex` profile, a differently-shaped file). Those two are the only marketplace outputs apm has profiles for — the third root manifest, `.github/plugin/marketplace.json` (Copilot CLI's legacy path), is a byte-identical mirror of the Claude one maintained by `scripts/sync-marketplace-mirror.sh` and gated by the `check-marketplace-mirror-sync` pre-push hook.
|
||||
- **`scripts/sync-plugin-content.sh` compiles the content mirror** (ADR-0017). It wraps `apm pack --format plugin` and copies the resulting bundle's flat `agents/`, `skills/`, `commands/`, `instructions/`, `extensions/`, and merged `hooks/hooks.json` back to the plugin root. Claude Code's installer convention-scans those flat paths and has no `.apm/` awareness whatsoever, so the mirror exists solely to satisfy the host's discovery contract.
|
||||
|
||||
`.apm/` is the sole hand-edited authoring source for plugin content. An edit made in the flat mirror is discarded by the next sync and is reported as drift by the `check-plugin-content-sync` pre-push hook. Hand-authored material that is not an `.apm/` primitive — `README.md`, `docs/`, `bin/`, `sources.md`, and `.mcp.json` — lives at the plugin root and is untouched by either compiler.
|
||||
`.apm/` is the sole hand-edited authoring source for plugin content. An edit made in the flat mirror is discarded by the next sync and is reported as drift by the `check-plugin-content-sync` pre-push hook. Hand-authored material that is not an `.apm/` primitive — `README.md`, `docs/`, `bin/`, `sources.md`, `.mcp.json`, and per-plugin extras such as `plugins/git/config.example.json`, `plugins/gitea/references/` and `plugins/bin/evals/` — lives at the plugin **root** and is untouched by either compiler.
|
||||
|
||||
That immunity is positional, not by filename. Anything placed *inside* a mirrored directory is destroyed regardless of what it is: `sync_dir` runs `rm -rf "$dst"` before every copy, and `sync_hooks_json` does the same to `hooks/`. A hand-written `README.md` under `plugins/<name>/hooks/` or `plugins/<name>/skills/` is deleted by the next sync with no drift report, because a file with no `.apm/` counterpart is simply absent from the regenerated tree. This has already cost the repo one document — `plugins/kyberforge/hooks/README.md`, since restored to `plugins/kyberforge/docs/hooks.md`. Plugin-root documentation belongs in `docs/`.
|
||||
|
||||
## Governance layer
|
||||
|
||||
@@ -51,7 +53,9 @@ This repo uses two `AGENTS.md` files as the provider-agnostic source of always-o
|
||||
|
||||
Both `CLAUDE.md` files are thin adapters: they import from their respective `AGENTS.md` and add only Claude Code-specific syntax (`@import`, content index paths). They carry no original always-on content.
|
||||
|
||||
This repo also has a `CLAUDE.md` at its root — the Claude Code entry point for working in this repo. It imports `AGENTS.md` and `CONTEXT.md`, nothing more. This is distinct from `providers/claude-code/CLAUDE.md`, which is the global config deployed to `~/.claude/`.
|
||||
This repo also has a `CLAUDE.md` at its root — the Claude Code entry point for working in this repo. It imports `AGENTS.md` and nothing else; there is no `@CONTEXT.md` import. It is not import-only either: below the import sits a fenced `<!-- rtk-instructions v2 -->` … `<!-- /rtk-instructions -->` block carrying the RTK command-prefix convention, which is tool-specific content with no `AGENTS.md` source. This is distinct from `providers/claude-code/CLAUDE.md`, which is the global config deployed to `~/.claude/`.
|
||||
|
||||
`CONTEXT.md` is therefore **not** always-loaded. `AGENTS.md` instructs agents to read it at session start, which is a behavioural instruction, not an `@import` guarantee — `LESSONS.md`'s 2026-05-17 entry proposed adding the import and it was never applied. Treat that entry as open work rather than a record of a landed change.
|
||||
|
||||
## Provider model
|
||||
|
||||
@@ -59,4 +63,4 @@ This repo also has a `CLAUDE.md` at its root — the Claude Code entry point for
|
||||
|
||||
## Architectural decisions
|
||||
|
||||
Key hard-to-reverse decisions are recorded as ADRs in `docs/adr/`. See the index there for rationale on choices like the pull distribution model, copy-not-symlink coupling, and the two-tier CLAUDE.md structure.
|
||||
Key hard-to-reverse decisions are recorded as ADRs in `docs/adr/`. There is no index file — the directory holds 17 numbered ADRs whose filenames state their decision, so `ls docs/adr/` is the index. Read a superseding ADR before the one it supersedes: ADR-0015 (apm as the authoring source of truth) supersedes ADR-0001 and moots ADR-0006, and ADR-0017 corrects ADR-0015's host-discovery gap. Entry points for the structure described on this page: ADR-0002 (two-tier CLAUDE.md), ADR-0003 (AGENTS.md as the provider-agnostic entry point), ADR-0015 and ADR-0017 (the two compilers behind the plugin roots).
|
||||
|
||||
Reference in New Issue
Block a user