feat(kyberforge): execute the plugin→APM conversion #95

Merged
Defame1297 merged 49 commits from feat/90-execute-apm-conversion into main 2026-08-14 14:32:36 +00:00
Collaborator

Closes #90
Closes #96
Closes #97

Converts all six plugins (bin, core, git, gitea, kyberforge, lint) from hand-maintained .claude-plugin/plugin.json + .claude-plugin/marketplace.json to Microsoft APM (apm.yml + .apm/) as the authoring source of truth, per ADR-0015. plugin.json/marketplace.json are now compiled output.

What changed

  • Phase A — per-plugin conversion: each plugin gets its own apm.yml; skills moved to .apm/skills/, agents re-authored as single-file .apm/agents/*.agent.md per ADR-0016 (Claude-only fields stripped), hooks moved to .apm/hooks/.
  • Phase B/C — marketplace registration + compile: root apm.yml registers all six local packages plus the remote mattpocock-skills source; .claude-plugin/marketplace.json, .agents/plugins/marketplace.json (codex) and .github/plugin/marketplace.json are now compiled/synced outputs instead of hand-edited.
  • Phase D — cleanup: removed plugin-author/marketplace-author skills (superseded by apm-workflow); updated .pre-commit-config.yaml and scripts/check-scope-walkup-sync.sh for the new .apm/ path shapes; updated CONTEXT.md and ADRs 0001/0006/0010; added ADR-0017 (content mirror bridges apm to host discovery).
  • Bridged apm's compiled content to Claude Code's flat plugin discovery (38f1ba4).
  • Added apm-native marketplace/audit/pack drift gates as local pre-push hooks (6e77c11).
  • Enabled Codex marketplace output (5f42f57).

Deliberate deviation from ADR-0017

9c140ef re-injects mcpServers into .github/plugin/plugin.json after apm pack strips it. ADR-0017 otherwise rejects patching apm's compiled output; this is a documented amendment, because apm's justification for stripping the field is contradicted by this repo's own researched Copilot schema — unlike the pointer fields ADR-0017 correctly left alone. It applies to bin only (the one plugin with a non-empty .mcp.json).

Unrelated fix riding along

925f04a fixes a false negative in plugins/core/.apm/skills/agentsmd-audit/scripts/validate-secrets.sh: the placeholder allowlist was matched against the whole line before any secret pattern ran, so an incidental example in a trailing comment suppressed detection of a real credential earlier on the same line. Flagged explicitly because it is a security fix unrelated to the APM conversion.

Issues #96 and #97 folded in (fifth round)

Disclosed rather than silent, because an earlier review of this PR flagged undisclosed scope as a problem. Both issues were originally filed out of this PR for exactly that reason; folding them back in is a deliberate reversal, made at the author's request.

#96 widens the diff into providers/, an area this PR did not previously touch. It is one commit (49d21bc) and is cleanly separable if a reviewer would rather it went to main on its own.

Five commits, 49d21bc..0f0ac58:

commit scope
49d21bc #96 — guard the statusline's empty-array expansion; add providers to the bash-3.2 scan
73393b9 #97-2 — correct three shellcheck source= directives that resolved to nothing
5a61b41 #97-3, #97-4 — bring the generated hooks/ directory under the mirror's ownership
c442f7e #97-1, #97-5 — decide .vale.ini readability by reading it; stop paying for vale where nothing lints
0f0ac58 #97-6 — reconcile AGENTS.md's pre-push hook count

Four issue claims did not survive verification

  • #97 item 4 is refuted. The issue reports .apm/prompts/ as documented-but-never-mirrored. MIRROR_DIRS lists destination directories, and apm folds .apm/prompts/ into commands/ (renaming *.prompt.md → *.md) — verified empirically against a scratch plugin. A plugin adding .apm/prompts/ is mirrored today. Adding the prompts entry the issue asks for would have named an output directory apm never emits. Pinned instead with a characterization test that fires if that mapping ever changes.
  • #97 item 2's suggested fix is wrong. The issue names tests/run-bats.sh:54 (../scripts/lib/batch-run.sh) as "the correct spelling". Directives resolve against the source-path, which under pre-commit is the repo root, so ../scripts/... escapes the repo and trips SC1091 exactly as the reported lib/... does. Both were broken. The spelling satisfying both shellcheck and test-vale-wrap.sh's two-candidate heuristic is repo-root-relative. A third instance the issue never mentioned, scripts/check-manifests.sh, was broken the same way.
  • #97 item 1 is worse than reported. The guard was not merely untested — it was dead. [[ -r ]] is access(2), which for uid 0 returns true even on a mode-000 file. This hook runs at pre-push and the environment is root, so it could never fire where it exists to fire. That is why no uid-independent test existed: there was nothing to test. Readability is now decided by actually reading.
  • #96's hazard is unreproducible on a modern dev box. bash 4.4 stopped treating an empty-array expansion as unbound, so the pre-fix code runs clean under set -u on bash 5.x and aborts only on macOS's bash 3.2. A runtime test cannot demonstrate it without a 3.2 binary — which is why the enforcement is the static scan, and why its floor must never be dropped to zero.

Two defects neither issue mentioned

  • scripts/check-manifests.sh carried the same unresolvable source= directive (above).
  • The new hooks/ wipe tripped SC2115: set -u aborts on an unset variable but not an empty one, so an empty $target_dir would make it rm -rf /hooks. Guarded with ${target_dir:?}. This would have failed pre-commit, which pins --severity=warning.

#97 item 5: fixed, but a net regression — read this before believing the 3.5×

test-check-vale-style-sync.sh drops 51s → 32s (~3.5× on its vale-bound cases) by masking vale in the 21 of 28 script runs that only assert .vale.ini text, via the PATH_NO_VALE mechanism case 12 already builds. The helper falls back to an unmasked run rather than skipping, so a machine where masking is unavailable loses speed, never coverage.

But the suite as a whole went 114s → 129s. The saving was more than eaten by the tests added here: test-sync-plugin-content.sh went 37s → 64s, because each new hooks/-stray and prompts case runs a real apm pack. The bottleneck moved rather than disappearing. Defensible for coverage on a generator whose output ships to users, but it is not a speedup and should not be recorded as one.

The masking turned out to be a coverage gain, which matters more than the time. With vale on PATH, cases 8 and 9 could not detect deletion of the assertions they were written to catch — a dropped StylesPath also breaks the glob probe, so the script exited 1 for the wrong reason and both cases went green. Verified against the pre-change files: the same mutation was caught by one incidental assertion before, and by three after.

Mutation evidence

Every new guard was verified to fail under the mutation it exists to catch, not merely to pass:

mutation result
restore the stale source=lib/batch-run.sh FAIL … tests/run-tests.sh (0/1)
restore bare "${parts[@]}" in the statusline FAIL: bash-4-only construct(s) in 44 scanned script(s)
point the providers glob at a wrong path FAIL … derived 0 file(s), under its floor of 1
delete the hooks/ wipe 36 passed, 3 failed
narrow checked_paths back to the file 39 passed, 2 failed
.vale.ini readability guard → if false 43 passed, 1 failed

The .vale.ini case asserts the message, not the exit code: with the guard removed the script still exits 1, just blaming a missing StylesPath on a file that has one. An exit-code-only test would have been green with the guard deleted.

Also caught by the new gate

The stricter hooks/ check found three empty plugins/{git,gitea,core}/hooks/ directories — untracked local cruft, not anything this PR ships (git does not track empty directories, so a fresh clone never had them). sync-plugin-content.sh --all cleared them and touched no tracked file.

Review rounds

Four review rounds ran against this branch before the issues above. The fourth (two independent reviewers, ~15 sub-agents, per-cluster clean-context verification with re-fix loops) found and fixed 28 findings, committed as f6cf83c, d4fa4b7, 413a750, bb9158d, 013b913.

Highlights, because several were invisible to every earlier round:

  • Critical: the content mirror's tests/ exclusion was depth-agnostic and deleted skill-author/assets/templates/tests/, a template the skill scaffolds from. The shipped scaffolder exited 2 in the installed plugin while the .apm/ copy worked. --check was green about it.
  • Four gates reported success in exactly the states they exist to reject: a silenced Vale rule (= false, = 0, lowercase = yes all bypassed a blocklist that should have been an allowlist), a drift check that never fired on compiled manifests or deletions, a mirror check that failed open, and an empty TAP stream reading as a pass.
  • 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. Now relocated, with no plugin.json pointer added.
  • The compiled manifests committed here were not apm output: json.dumps(indent=2, sort_keys=True) reproduced all three byte-for-byte, i.e. pretty-format-json --autofix had rewritten them before an exclusion existed. Corrected and prevented from recurring.
  • Issue #90's resolved-decision 2 (version-bump policy documentation) was specified but never written; its previous carrier, marketplace-author, is deleted by this PR. Now written into apm-workflow's reference files.

Most of the added lines are tests. Mutation testing showed the previous suite was green with the stale-skill wipe, three MIRROR_DIRS entries, or the hooks newline normalization deleted; the current suite kills 18 of 19 mutations.

Verification

There is no CI in this repo — no .github/workflows/, no Gitea Actions. All gates are local pre-push hooks in .pre-commit-config.yaml, so merging via the Gitea web UI runs nothing. Merge from a local push, or run pre-commit run --hook-stage pre-push --all-files first.

Last run on 0f0ac58, all green:

  • bash tests/run-tests.sh → 16/16 scripts, 0 failures
  • All 14 pre-push hooks Passed, including apm marketplace check (network), apm audit --ci, apm pack --check-clean, and both claude-CLI validators
  • shellcheck --severity=warning clean on all nine changed shell files

Note apm audit --ci currently runs a single check that passes because no dependencies are declared, and warns that org-policy enforcement was skipped — it is a weaker gate than its name suggests.

Known accepted consequences

  • displayName is dropped from all six compiled plugin.json files and owner.email from the marketplace manifest — apm.yml has no equivalent key. Optional fields, so --strict validation still passes. Recorded in ADR-0015.
  • mattpocock-skills is now version-pinned (^1.2.0 → v1.2.3) rather than tracking the default branch, and must be bumped manually.
  • Plugin-scope agents lose per-agent tools: restriction per ADR-0016; lint-runner is report-only by instruction, not capability.
  • The test suite is ~13% slower than before this round (129s vs 114s), for the reason given under #97 item 5.

🤖 Generated with Claude Code

Closes #90 Closes #96 Closes #97 Converts all six plugins (`bin`, `core`, `git`, `gitea`, `kyberforge`, `lint`) from hand-maintained `.claude-plugin/plugin.json` + `.claude-plugin/marketplace.json` to Microsoft APM (`apm.yml` + `.apm/`) as the authoring source of truth, per ADR-0015. `plugin.json`/`marketplace.json` are now compiled output. ## What changed - **Phase A — per-plugin conversion**: each plugin gets its own `apm.yml`; skills moved to `.apm/skills/`, agents re-authored as single-file `.apm/agents/*.agent.md` per ADR-0016 (Claude-only fields stripped), hooks moved to `.apm/hooks/`. - **Phase B/C — marketplace registration + compile**: root `apm.yml` registers all six local packages plus the remote `mattpocock-skills` source; `.claude-plugin/marketplace.json`, `.agents/plugins/marketplace.json` (codex) and `.github/plugin/marketplace.json` are now compiled/synced outputs instead of hand-edited. - **Phase D — cleanup**: removed `plugin-author`/`marketplace-author` skills (superseded by `apm-workflow`); updated `.pre-commit-config.yaml` and `scripts/check-scope-walkup-sync.sh` for the new `.apm/` path shapes; updated `CONTEXT.md` and ADRs 0001/0006/0010; added **ADR-0017** (content mirror bridges apm to host discovery). - Bridged apm's compiled content to Claude Code's flat plugin discovery (`38f1ba4`). - Added apm-native marketplace/audit/pack drift gates as **local pre-push hooks** (`6e77c11`). - Enabled Codex marketplace output (`5f42f57`). ### Deliberate deviation from ADR-0017 `9c140ef` re-injects `mcpServers` into `.github/plugin/plugin.json` after `apm pack` strips it. ADR-0017 otherwise rejects patching apm's compiled output; this is a documented amendment, because apm's justification for stripping the field is contradicted by this repo's own researched Copilot schema — unlike the pointer fields ADR-0017 correctly left alone. It applies to `bin` only (the one plugin with a non-empty `.mcp.json`). ### Unrelated fix riding along `925f04a` fixes a false negative in `plugins/core/.apm/skills/agentsmd-audit/scripts/validate-secrets.sh`: the placeholder allowlist was matched against the whole line before any secret pattern ran, so an incidental `example` in a trailing comment suppressed detection of a real credential earlier on the same line. Flagged explicitly because it is a security fix unrelated to the APM conversion. ## Issues #96 and #97 folded in (fifth round) **Disclosed rather than silent, because an earlier review of this PR flagged undisclosed scope as a problem.** Both issues were originally filed *out* of this PR for exactly that reason; folding them back in is a deliberate reversal, made at the author's request. **#96 widens the diff into `providers/`, an area this PR did not previously touch.** It is one commit (`49d21bc`) and is cleanly separable if a reviewer would rather it went to `main` on its own. Five commits, `49d21bc..0f0ac58`: | commit | scope | |---|---| | `49d21bc` | #96 — guard the statusline's empty-array expansion; add `providers` to the bash-3.2 scan | | `73393b9` | #97-2 — correct three `shellcheck source=` directives that resolved to nothing | | `5a61b41` | #97-3, #97-4 — bring the generated `hooks/` directory under the mirror's ownership | | `c442f7e` | #97-1, #97-5 — decide `.vale.ini` readability by reading it; stop paying for vale where nothing lints | | `0f0ac58` | #97-6 — reconcile AGENTS.md's pre-push hook count | ### Four issue claims did not survive verification - **#97 item 4 is refuted.** The issue reports `.apm/prompts/` as documented-but-never-mirrored. `MIRROR_DIRS` lists *destination* directories, and apm folds `.apm/prompts/` into `commands/` (renaming `*.prompt.md` → `*.md`) — verified empirically against a scratch plugin. A plugin adding `.apm/prompts/` **is** mirrored today. Adding the `prompts` entry the issue asks for would have named an output directory apm never emits. Pinned instead with a characterization test that fires if that mapping ever changes. - **#97 item 2's suggested fix is wrong.** The issue names `tests/run-bats.sh:54` (`../scripts/lib/batch-run.sh`) as "the correct spelling". Directives resolve against the source-path, which under pre-commit is the repo root, so `../scripts/...` escapes the repo and trips SC1091 exactly as the reported `lib/...` does. Both were broken. The spelling satisfying both shellcheck and `test-vale-wrap.sh`'s two-candidate heuristic is repo-root-relative. A third instance the issue never mentioned, `scripts/check-manifests.sh`, was broken the same way. - **#97 item 1 is worse than reported.** The guard was not merely untested — it was **dead**. `[[ -r ]]` is `access(2)`, which for uid 0 returns true even on a mode-000 file. This hook runs at pre-push and the environment is root, so it could never fire where it exists to fire. That is *why* no uid-independent test existed: there was nothing to test. Readability is now decided by actually reading. - **#96's hazard is unreproducible on a modern dev box.** bash 4.4 stopped treating an empty-array expansion as unbound, so the pre-fix code runs clean under `set -u` on bash 5.x and aborts only on macOS's bash 3.2. A runtime test cannot demonstrate it without a 3.2 binary — which is why the enforcement is the static scan, and why its floor must never be dropped to zero. ### Two defects neither issue mentioned - `scripts/check-manifests.sh` carried the same unresolvable `source=` directive (above). - The new `hooks/` wipe tripped `SC2115`: `set -u` aborts on an *unset* variable but not an *empty* one, so an empty `$target_dir` would make it `rm -rf /hooks`. Guarded with `${target_dir:?}`. This would have failed pre-commit, which pins `--severity=warning`. ### #97 item 5: fixed, but a net regression — read this before believing the 3.5× `test-check-vale-style-sync.sh` drops **51s → 32s** (~3.5× on its vale-bound cases) by masking vale in the 21 of 28 script runs that only assert `.vale.ini` text, via the `PATH_NO_VALE` mechanism case 12 already builds. The helper falls back to an unmasked run rather than skipping, so a machine where masking is unavailable loses speed, never coverage. **But the suite as a whole went 114s → 129s.** The saving was more than eaten by the tests added here: `test-sync-plugin-content.sh` went **37s → 64s**, because each new `hooks/`-stray and `prompts` case runs a real `apm pack`. The bottleneck moved rather than disappearing. Defensible for coverage on a generator whose output ships to users, but it is not a speedup and should not be recorded as one. The masking turned out to be a **coverage gain**, which matters more than the time. With vale on `PATH`, cases 8 and 9 could not detect deletion of the assertions they were written to catch — a dropped `StylesPath` also breaks the glob probe, so the script exited 1 for the wrong reason and both cases went green. Verified against the pre-change files: the same mutation was caught by one incidental assertion before, and by three after. ### Mutation evidence Every new guard was verified to **fail** under the mutation it exists to catch, not merely to pass: | mutation | result | |---|---| | restore the stale `source=lib/batch-run.sh` | `FAIL … tests/run-tests.sh (0/1)` | | restore bare `"${parts[@]}"` in the statusline | `FAIL: bash-4-only construct(s) in 44 scanned script(s)` | | point the `providers` glob at a wrong path | `FAIL … derived 0 file(s), under its floor of 1` | | delete the `hooks/` wipe | `36 passed, 3 failed` | | narrow `checked_paths` back to the file | `39 passed, 2 failed` | | `.vale.ini` readability guard → `if false` | `43 passed, 1 failed` | The `.vale.ini` case asserts the **message**, not the exit code: with the guard removed the script still exits 1, just blaming a missing `StylesPath` on a file that has one. An exit-code-only test would have been green with the guard deleted. ### Also caught by the new gate The stricter `hooks/` check found three empty `plugins/{git,gitea,core}/hooks/` directories — untracked local cruft, not anything this PR ships (git does not track empty directories, so a fresh clone never had them). `sync-plugin-content.sh --all` cleared them and touched no tracked file. ## Review rounds Four review rounds ran against this branch before the issues above. The fourth (two independent reviewers, ~15 sub-agents, per-cluster clean-context verification with re-fix loops) found and fixed **28 findings**, committed as `f6cf83c`, `d4fa4b7`, `413a750`, `bb9158d`, `013b913`. Highlights, because several were invisible to every earlier round: - **Critical**: the content mirror's `tests/` exclusion was depth-agnostic and deleted `skill-author/assets/templates/tests/`, a template the skill scaffolds *from*. The shipped scaffolder exited 2 in the installed plugin while the `.apm/` copy worked. `--check` was green about it. - Four gates reported success in exactly the states they exist to reject: a silenced Vale rule (`= false`, `= 0`, lowercase `= yes` all bypassed a blocklist that should have been an allowlist), a drift check that never fired on compiled manifests or deletions, a mirror check that failed open, and an empty TAP stream reading as a pass. - 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. Now relocated, with no `plugin.json` pointer added. - The compiled manifests committed here were **not apm output**: `json.dumps(indent=2, sort_keys=True)` reproduced all three byte-for-byte, i.e. `pretty-format-json --autofix` had rewritten them before an exclusion existed. Corrected and prevented from recurring. - Issue #90's resolved-decision 2 (version-bump policy documentation) was specified but never written; its previous carrier, `marketplace-author`, is deleted by this PR. Now written into `apm-workflow`'s reference files. Most of the added lines are tests. Mutation testing showed the previous suite was green with the stale-skill wipe, three `MIRROR_DIRS` entries, or the hooks newline normalization deleted; the current suite kills 18 of 19 mutations. ## Verification **There is no CI in this repo** — no `.github/workflows/`, no Gitea Actions. All gates are local pre-push hooks in `.pre-commit-config.yaml`, so **merging via the Gitea web UI runs nothing**. Merge from a local push, or run `pre-commit run --hook-stage pre-push --all-files` first. Last run on `0f0ac58`, all green: - `bash tests/run-tests.sh` → 16/16 scripts, 0 failures - All 14 pre-push hooks Passed, including `apm marketplace check` (network), `apm audit --ci`, `apm pack --check-clean`, and both `claude`-CLI validators - `shellcheck --severity=warning` clean on all nine changed shell files Note `apm audit --ci` currently runs a single check that passes because no dependencies are declared, and warns that org-policy enforcement was skipped — it is a weaker gate than its name suggests. ## Known accepted consequences - `displayName` is dropped from all six compiled `plugin.json` files and `owner.email` from the marketplace manifest — `apm.yml` has no equivalent key. Optional fields, so `--strict` validation still passes. Recorded in ADR-0015. - `mattpocock-skills` is now version-pinned (`^1.2.0` → `v1.2.3`) rather than tracking the default branch, and must be bumped manually. - Plugin-scope agents lose per-agent `tools:` restriction per ADR-0016; `lint-runner` is report-only by instruction, not capability. - The test suite is ~13% slower than before this round (129s vs 114s), for the reason given under #97 item 5. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
Claude added 10 commits 2026-08-13 19:06:34 +00:00
Why:
ADR-0015 established that Microsoft APM (apm.yml + .apm/) should replace
this repo's hand-authored plugin.json/marketplace.json model, with those
files becoming compiled output of `apm pack` instead of files edited by
hand via the (now-retired) plugin-author/marketplace-author skills.
Issue #90 was the deferred execution of that decision, gated on #88
(apm tooling) and #89 (apm-native agent-author/skill-author routing).

Implementation notes:
- All six plugins (bin, core, git, gitea, kyberforge, lint) now carry
  apm.yml + .apm/{skills,agents,hooks} as their authoring source. Skills
  moved with a plain git mv (content-identical across targets). Agents
  were re-authored, not moved: per ADR-0016, .apm/agents/*.agent.md
  compiles verbatim to both Claude and Copilot, so plugin-scope agents
  now carry only name/description/model/source_keys -- no tools: field,
  no Claude-only knobs (isolation, maxTurns, effort, memory,
  permissionMode).
- Root apm.yml registers all 7 marketplace packages (6 local plus
  mattpocock-skills as a remote entry) under versioning: per_package,
  matching this repo's existing independent-plugin-versioning practice.
- .claude-plugin/marketplace.json and every plugin's plugin.json are now
  apm-pack-compiled output, verified against the prior hand-maintained
  content: same names/descriptions/versions/licenses/authors, only
  cosmetic serialization differences (JSON key order, owner email vs.
  url, Unicode escaping).
- plugin-author and marketplace-author are retired now that apm-based
  authoring fully replaces their job; kyberforge bumped 1.3.1 -> 1.4.0
  for that removal, and the root marketplace catalog bumped
  0.3.1 -> 0.3.2 to match, per the version-bump convention now
  documented in apm-workflow's reference docs instead of a dedicated
  script (apm has no native version-bump automation).
- Fixed hardcoded pre-.apm/ path assumptions across
  .pre-commit-config.yaml, .pre-commit-hooks.yaml,
  scripts/check-scope-walkup-sync.sh, scripts/sync-vale-styles.sh,
  scripts/check-vale-style-sync.sh, six plugins' root plugin.json
  (stale skills/hooks/agents pointer fields that check-manifests.sh
  validates), and several tests/*.bats and tests/*.sh fixtures --
  including a bats REPO_ROOT relative-path depth bug (10 files, one
  extra .apm/ directory level to walk up) and a vale probe-path
  isolation regression introduced mid-fix.
- Corrected empirically-wrong assumptions surfaced this session in
  apm-workflow/apm-install's own reference docs: `apm marketplace
  package add` does not accept local paths (only owner/repo remote
  shorthand -- local packages are registered by editing apm.yml's
  marketplace.packages[] directly); `apm compile` is a consumer-side
  AGENTS.md/CLAUDE.md generator, not the plugin.json producer, and
  hard-fails on skill/agent-only packages without --clean; `apm plugin
  init <name>` nests a stray subdirectory when run with a positional
  name arg from inside a same-named directory; no native Copilot
  marketplace output profile exists; .mcp.json is merged into the
  compiled plugin.json content-aware and target-scoped, with no
  dependencies.mcp entry needed for simple passthrough; pipx is the
  correct pip fallback on externally-managed Python environments.
- Renamed agent-author's copilot.agent.md template asset to
  copilot.agent.md.template so apm compile's recursive *.agent.md glob
  stops misparsing the placeholder template as a real agent primitive.

Impact:
plugin.json and marketplace.json are compiled artifacts from here on --
editing them by hand is no longer the workflow; edit apm.yml/.apm/ and
run apm pack. CONTEXT.md's Plugin/Plugin marketplace glossary entries
reflect this. ADR-0001 is marked superseded, ADR-0006 moot, and
ADR-0010 updated for the new .apm/agents/ path (project/user scope
unaffected, per ADR-0016). Full local verification: claude plugin
validate --strict on all 6 plugins, apm audit --ci, apm marketplace
check, check-manifests.sh, and the full test suite (165/165 bats,
13/13 shell scripts) all pass clean.

Fixes: #90
Refs: #88, #89
ADR: 0015
ADR: 0016

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ub96PyaSRD9BHPktotj1pC
The apm conversion (5e23250) moved skills/agents to plugins/<name>/.apm/
and deleted plugin-author/marketplace-author, but a review against issue
#90's Definition of Done found several stale pre-conversion references
left behind by straight git-mv's, plus one real content gap:

- ADR-0014 still documented the old flat vale-prefilter paths in 6
  places, despite ADR-0015 claiming it had been updated.
- ADR-0015 also overclaimed: it said ADR-0014 had both a skills/ and an
  agents/ path regex updated, but ADR-0014 never had an agents/ path
  regex to begin with (its one "agents" mention is a Vale glob-section
  identifier, not a filesystem path). Corrected the wording.
- CONTEXT.md's Skill glossary entry, its Vale-prefilter section, and its
  plugin-author/marketplace-author forward-pointer all still described
  the pre-conversion layout or a "pending issue #90" state that has since
  landed.
- LESSONS.md and two skill test READMEs pointed at skill-author/
  skill-audit/agent-author paths without the .apm/ segment.
- apm-workflow/references/marketplace.md cited plugin-author/SKILL.md's
  Gotchas for the Claude Code reserved plugin-name-prefix list, but that
  list was never actually carried into apm-workflow during the
  conversion despite ADR-0015 claiming it was. Recovered the list from
  git history and inlined it directly rather than leaving a dangling
  citation.
- agent-author/references/deployment-modes.md had an example
  contradicting its own stated .apm/agents/ convention two lines above.

Refs: #90
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
Validated the plugin-content-mirror fix (issue #90) against apm's own
packing/CI documentation and source: no apm-native mechanism replaces the
mirror script (apm's bundler treats .apm/ and root convention dirs as
mutually exclusive, by design), but the investigation surfaced a real,
separate gap -- this repo ran zero apm-native audit/check commands in CI,
relying entirely on custom scripts and Claude Code's own client-side
validator.

Add three pre-push hooks matching apm's documented producer CI pattern:
- apm marketplace check: validates every marketplace.packages[] entry
  resolves, including live network reachability for remote refs -- a
  blind spot check-manifests.sh explicitly skips (local sources only).
- apm audit --ci: apm's own lockfile/policy/hidden-content integrity gate.
- apm pack --check-versions --check-clean: closes issue #90's deferred
  item 3 (a check-clean-equivalent gate) using apm's native flag instead
  of bespoke drift logic, verifying .claude-plugin/marketplace.json still
  matches what apm.yml + .apm/ would currently generate.

All three are network-tolerant and whole-repo in scope, so they belong at
pre-push alongside check-manifests/check-plugin-content-sync/
validate-plugins -- not pre-commit, which stays fast/offline/per-file.

Documented the packing/bundling/releasing/CI findings in
docs/research/docs/microsoft-apm/releasing.md (new) and extended
testing-and-validation.md with the apm-action wrapper and its documented
CI patterns, sourced from Context7 and cross-checked against the
installed apm-cli 0.28.0 package directly.

Refs: #90
Uncomment marketplace.outputs.codex in apm.yml. Codex output requires
every package to declare a category -- added one per package (Developer
Tools / Utilities / Version Control / Productivity, matching each
plugin's actual scope).

Re-running apm pack after enabling codex auto-synced 'codex' into each
local package's own targets: list (apm.yml, per plugin) -- packages
belonging to a marketplace that publishes a codex output need to declare
codex as a supported compile target for that to be coherent. Verified
idempotent: a second apm pack --check-clean run makes no further changes.

.claude-plugin/marketplace.json regenerated (now carries category per
plugin); .agents/plugins/marketplace.json is the new codex artifact.

Refs: #90
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
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
Both runners spawned one process at a time and let it finish before starting
the next, so wall time scaled with file count even though each file's own
work (bats: mostly repeated apm/git subprocess startup; the vale-heavy
shell scripts: repeated vale binary startup) rarely used a full core.

run-bats.sh now backgrounds one `bats` invocation per file, bounded by core
count, buffering each file's TAP output so concurrent streams can't
interleave, then flushes in stable sorted order once every job finishes.
run-tests.sh does the same for test-*.sh in fixed-size batches (plain `wait`,
not `wait -n`, to stay on the bash-3.2-safe path test-vale-wrap.sh already
enforces for this file) -- verified beforehand that every test-*.sh keeps its
fixtures under its own mktemp dir rather than mutating the live repo tree, so
running them concurrently is safe.

Confirmed correct on both the pass and fail paths (a deliberately failing
bats test and a scratch TEST_DIR with pass/fail/skip-77 scripts) before
measuring: full bats suite 26-30s -> 7.5s, tests/run-tests.sh 68s -> ~45-50s,
full pre-push gate 2m40s -> 1m12s.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01X7GvKuJfy2WrdBmUttV4DT
apm.yml only declares claude and codex marketplace output profiles --
codex writes a differently-shaped file to .agents/plugins/marketplace.json,
so nothing regenerates the legacy Copilot CLI path at
.github/plugin/marketplace.json. It was hand-synced once during the #90
conversion and had drifted (missing the category field added when codex
output was enabled) with no gate to catch it.

scripts/sync-marketplace-mirror.sh keeps it byte-identical to the compiled
.claude-plugin/marketplace.json, wired as a pre-push check so it can't go
stale silently again.

Refs: #90
bin and kyberforge had empty keywords: [], and no plugin set homepage or
repository despite the schema supporting both and the repo having one
canonical Gitea remote -- flagged by an apm-docs-vs-repo audit following
issue #90's plugin->apm conversion.

Compiled .claude-plugin/plugin.json and .github/plugin/plugin.json were
regenerated via scripts/sync-plugin-content.sh to carry the new fields.

Refs: #90
Defame1297 added 3 commits 2026-08-13 19:49:39 +00:00
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
run-bats.sh's new bounded parallel dispatcher used nproc/wait -n,
which are bash 4.3+/GNU-only and silently drop the concurrency cap on
macOS's stock bash 3.2 (the wait -n error is swallowed by `|| true`).
Its sibling tests/run-tests.sh, changed in the same PR and explicitly
bash-3.2-safe, already solves this with getconf + a batched wait.
Ported that same pattern here for consistency and to actually meet the
compatibility goal.

Refs: #95
The Vale-audit-prefilter glossary entry still quoted the pre-.apm/-
migration file-scoping regexes (^plugins/[^/]+/skills/...,
^plugins/[^/]+/agents/...). The actual pre-commit hooks require .apm/
in the path and .agent.md for agent files; updated the glossary to
quote the live patterns from .pre-commit-config.yaml verbatim,
including a third stale occurrence in the skill-size-check description
that the PR #95 review missed.

Refs: #95
Author
Collaborator

Review-and-fix cycle complete

A code review of this branch (the issue #90 apm-conversion migration) surfaced 10 findings, all now fixed and independently re-validated against the diff (several re-tested empirically, not just via the test suite).

Findings fixed:

  1. plugins/bin/.github/plugin/plugin.json was missing mcpServers — apm's Copilot-ecosystem builder unconditionally strips it, contradicting this repo's own researched Copilot schema docs. Narrowly reinjected on real syncs.
  2. scripts/sync-plugin-content.sh's --check claimed to never mutate the plugin root, but apm pack still wrote manifest files into it on first-time creation. --check now packs a throwaway copy instead.
  3. check-plugin-content-sync's pre-commit hook hardcoded the six plugin directories instead of deriving them. Added an --all flag (parses marketplace.json, matching check-manifests.sh's approach) and simplified the hook to use it.
  4. A missing plugin directory and one that legitimately has no .apm/ yet were both treated as SKIP/success. A missing directory now FAILs.
  5. tests/run-bats.sh's new parallel dispatcher used nproc/wait -n (bash 4.3+/GNU-only), breaking macOS bash-3.2 compat that its sibling tests/run-tests.sh explicitly targets. Ported the same getconf+batched-wait pattern.
  6. tests/test-sync-plugin-content.sh had two competing trap ... EXIT statements, silently leaking a tmp dir every run. Adopted the existing track()/CLEANUP_DIRS pattern from tests/test-check-release-needed.sh.
  7. CONTEXT.md's Vale-prefilter glossary quoted stale pre-.apm/-migration regexes (plus a third occurrence the original review missed).
  8. No concurrency cap on sync-plugin-content.sh's dispatch loop — added the same bash-3.2-safe cap as (5).
  9. Per-plugin scratch/log files keyed only by basename, with no collision guard — added a fail-fast check.
  10. Duplicated trailing-newline normalization logic — factored into one helper.

Note on finding #1's fix: reinjecting mcpServers is a deliberate, narrow exception to ADR-0017's stated rejection of patching apm's compiled output. apm's own justification for stripping the field is factually wrong per this repo's documented Copilot schema, unlike the fields ADR-0017 correctly left alone (skills/agents/commands, which really are host-auto-discovered). Documented as an amendment to ADR-0017 rather than silently deviating from it.

Commits:

  • 9c140ef — sync-plugin-content.sh hardening + mcpServers reinjection + ADR-0017 amendment
  • 4003c6a — run-bats.sh bash-3.2 compat
  • 7c3c867 — CONTEXT.md doc fix

Full test suite, shellcheck, and all pre-push gates (including apm audit --ci, apm marketplace check, plugin validation) pass.

## Review-and-fix cycle complete A code review of this branch (the issue #90 apm-conversion migration) surfaced 10 findings, all now fixed and independently re-validated against the diff (several re-tested empirically, not just via the test suite). **Findings fixed:** 1. `plugins/bin/.github/plugin/plugin.json` was missing `mcpServers` — apm's Copilot-ecosystem builder unconditionally strips it, contradicting this repo's own researched Copilot schema docs. Narrowly reinjected on real syncs. 2. `scripts/sync-plugin-content.sh`'s `--check` claimed to never mutate the plugin root, but `apm pack` still wrote manifest files into it on first-time creation. `--check` now packs a throwaway copy instead. 3. `check-plugin-content-sync`'s pre-commit hook hardcoded the six plugin directories instead of deriving them. Added an `--all` flag (parses `marketplace.json`, matching `check-manifests.sh`'s approach) and simplified the hook to use it. 4. A missing plugin directory and one that legitimately has no `.apm/` yet were both treated as SKIP/success. A missing directory now FAILs. 5. `tests/run-bats.sh`'s new parallel dispatcher used `nproc`/`wait -n` (bash 4.3+/GNU-only), breaking macOS bash-3.2 compat that its sibling `tests/run-tests.sh` explicitly targets. Ported the same `getconf`+batched-`wait` pattern. 6. `tests/test-sync-plugin-content.sh` had two competing `trap ... EXIT` statements, silently leaking a tmp dir every run. Adopted the existing `track()`/`CLEANUP_DIRS` pattern from `tests/test-check-release-needed.sh`. 7. `CONTEXT.md`'s Vale-prefilter glossary quoted stale pre-`.apm/`-migration regexes (plus a third occurrence the original review missed). 8. No concurrency cap on `sync-plugin-content.sh`'s dispatch loop — added the same bash-3.2-safe cap as (5). 9. Per-plugin scratch/log files keyed only by basename, with no collision guard — added a fail-fast check. 10. Duplicated trailing-newline normalization logic — factored into one helper. **Note on finding #1's fix:** reinjecting `mcpServers` is a deliberate, narrow exception to ADR-0017's stated rejection of patching apm's compiled output. apm's own justification for stripping the field is factually wrong per this repo's documented Copilot schema, unlike the fields ADR-0017 correctly left alone (skills/agents/commands, which really are host-auto-discovered). Documented as an amendment to ADR-0017 rather than silently deviating from it. **Commits:** - `9c140ef` — sync-plugin-content.sh hardening + mcpServers reinjection + ADR-0017 amendment - `4003c6a` — run-bats.sh bash-3.2 compat - `7c3c867` — CONTEXT.md doc fix Full test suite, shellcheck, and all pre-push gates (including `apm audit --ci`, `apm marketplace check`, plugin validation) pass.
Defame1297 added 2 commits 2026-08-13 20:53:49 +00:00
Its sibling sync-plugin-content.sh has thorough coverage
(tests/test-sync-plugin-content.sh) but this drift guard -- wired into
pre-commit as check-marketplace-mirror-sync, keeping
.claude-plugin/marketplace.json and .github/plugin/marketplace.json in
sync -- had none. A silent regression here would let the two
marketplace manifests drift without any test catching it.

Covers: missing-source no-op in both real-sync and --check modes,
drift detection when the mirror is missing or stale, real sync
producing a byte-identical mirror, --check clean immediately after
sync, drift from an edited source cleared by re-sync, an
already-in-sync mirror reporting no drift, usage-error rejection of
unrecognized/extra arguments, and idempotency of repeated syncs.
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.
Author
Collaborator

A prior review of this PR found it ready to merge overall, with one minor finding and one nit outstanding. Both are closed out by two follow-up commits just pushed:

Finding (minor) — fixed in 2c731eb: scripts/sync-marketplace-mirror.sh had no automated test coverage, unlike its sibling sync-plugin-content.sh. Added tests/test-sync-marketplace-mirror.sh with 11 test cases (missing-source no-ops, drift detection in --check mode, real-sync copy correctness, idempotency, usage-error handling). Verified the new tests actually catch regressions by mutating the script twice and confirming the right tests failed, then restored it. Full suite now 15/15 shell scripts + 165/165 bats tests passing.

Nit — addressed in 560154c: the live discoverability probe cited in ADR-0017 (claude --plugin-dir plugins/kyberforge -p "list your skills and agents") hadn't been independently re-run. Re-ran it and confirmed it reproduces the documented fixed state (7 kyberforge skills + 1 agent discovered, matching .apm/ 1:1). Found that running it literally from the repo root gives a contaminated signal, since this repo's own project-level .claude/settings.json enables all 6 holocron plugins, conflating kyberforge's discoverability with the other 5 plugins'. Documented as a reproduction-note caveat in ADR-0017's Consequences section: rerun from a neutral cwd outside the repo with an absolute --plugin-dir path to isolate the signal.

No blockers remain from the review; PR is ready to merge as far as this review is concerned.

A prior review of this PR found it ready to merge overall, with one minor finding and one nit outstanding. Both are closed out by two follow-up commits just pushed: **Finding (minor) — fixed in `2c731eb`**: `scripts/sync-marketplace-mirror.sh` had no automated test coverage, unlike its sibling `sync-plugin-content.sh`. Added `tests/test-sync-marketplace-mirror.sh` with 11 test cases (missing-source no-ops, drift detection in `--check` mode, real-sync copy correctness, idempotency, usage-error handling). Verified the new tests actually catch regressions by mutating the script twice and confirming the right tests failed, then restored it. Full suite now 15/15 shell scripts + 165/165 bats tests passing. **Nit — addressed in `560154c`**: the live discoverability probe cited in ADR-0017 (`claude --plugin-dir plugins/kyberforge -p "list your skills and agents"`) hadn't been independently re-run. Re-ran it and confirmed it reproduces the documented fixed state (7 kyberforge skills + 1 agent discovered, matching `.apm/` 1:1). Found that running it literally from the repo root gives a contaminated signal, since this repo's own project-level `.claude/settings.json` enables all 6 holocron plugins, conflating kyberforge's discoverability with the other 5 plugins'. Documented as a reproduction-note caveat in ADR-0017's Consequences section: rerun from a neutral cwd outside the repo with an absolute `--plugin-dir` path to isolate the signal. No blockers remain from the review; PR is ready to merge as far as this review is concerned.
Defame1297 added 2 commits 2026-08-13 21:30:31 +00:00
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
plugin-author was deleted by this PR (ADR-0015, apm-workflow is the
successor) but still appeared as a live example in the skill-composition
list. The Gitea-canonical-issue-tracker line also cited ADR-0017 (the
unrelated plugin-content-mirror ADR added by this PR) instead of ADR-0007.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01X7GvKuJfy2WrdBmUttV4DT
Author
Collaborator

Ran a full review (4 parallel sub-agents + verification pass). Found and fixed 4 issues, all pushed in commits b0b1470 and 911dadd:

Fixed:

  1. HIGH — scripts/sync-plugin-content.sh: force_flag array expanded unguarded ("${force_flag[@]}") under set -euo pipefail. Empty in --check mode, which is exactly how the pre-commit drift gate invokes this script (--check --all) — trips "unbound variable" on bash 3.2 (macOS default), silently defeating the ADR-0017 drift check. Fixed with the same ${arr[@]+"${arr[@]}"} guard the file already uses for seen_names.
  2. MEDIUM (latent) — same unguarded-expansion pattern on plugin_dirs, at 3 loop sites. Guarded identically.
  3. LOW — CONTEXT.md: skill-composition example list still cited plugin-author, which this same PR deletes (ADR-0015 successor is apm-workflow). Swapped in.
  4. LOW — CONTEXT.md: "Gitea is the canonical issue tracker" cited ADR-0017 (unrelated, this PR's new mirror-bridge ADR) instead of ADR-0007. Corrected.

Verified clean: manifest parity across all 6 plugins, .apm/ content integrity (byte-for-byte spot-check vs main), mcpServers reinjection scoping, provenance chains, agent single-file format compliance, no secrets/injection issues, Conventional Commits on all commits.

Tests: full local suite green both before and after fixes (15/15 scripts, bats). Push just now re-ran all pre-push hooks including Check plugin content sync — passed, confirming the fix resolves the gate it protects.

Recommendation: approve — no remaining blockers.

Ran a full review (4 parallel sub-agents + verification pass). Found and fixed 4 issues, all pushed in commits `b0b1470` and `911dadd`: **Fixed:** 1. **HIGH** — `scripts/sync-plugin-content.sh`: `force_flag` array expanded unguarded (`"${force_flag[@]}"`) under `set -euo pipefail`. Empty in `--check` mode, which is exactly how the pre-commit drift gate invokes this script (`--check --all`) — trips "unbound variable" on bash 3.2 (macOS default), silently defeating the ADR-0017 drift check. Fixed with the same `${arr[@]+"${arr[@]}"}` guard the file already uses for `seen_names`. 2. **MEDIUM** (latent) — same unguarded-expansion pattern on `plugin_dirs`, at 3 loop sites. Guarded identically. 3. **LOW** — `CONTEXT.md`: skill-composition example list still cited `plugin-author`, which this same PR deletes (ADR-0015 successor is `apm-workflow`). Swapped in. 4. **LOW** — `CONTEXT.md`: "Gitea is the canonical issue tracker" cited ADR-0017 (unrelated, this PR's new mirror-bridge ADR) instead of ADR-0007. Corrected. **Verified clean:** manifest parity across all 6 plugins, `.apm/` content integrity (byte-for-byte spot-check vs main), mcpServers reinjection scoping, provenance chains, agent single-file format compliance, no secrets/injection issues, Conventional Commits on all commits. **Tests:** full local suite green both before and after fixes (15/15 scripts, bats). Push just now re-ran all pre-push hooks including `Check plugin content sync` — passed, confirming the fix resolves the gate it protects. Recommendation: **approve** — no remaining blockers.
Defame1297 added 6 commits 2026-08-13 22:49:42 +00:00
The pre-push hook ran a full `apm pack` + directory diff across every
plugin on every push (always_run: true), even for changes that could
never affect the sync (e.g. a README edit). Add a files: regex scoped
to what scripts/sync-plugin-content.sh actually consumes -- each
plugin's apm.yml, .mcp.json (read by reinject_mcp_servers), .apm/**
(the sync source), the flat mirror output dirs themselves (agents/,
skills/, commands/, instructions/, extensions/, hooks.json -- so a
direct edit to compiled output is still caught as drift), the
marketplace.json --all reads to build the plugin list, and the sync
script itself -- and drop always_run now that files: covers every
input path.
validate-secrets.sh checked the placeholder allowlist regex against the
whole line before running any secret-pattern regex. An unrelated
placeholder-looking token anywhere on the line (e.g. "example" or
"your-token-here" in a trailing comment) suppressed detection of a real
credential earlier on the same line. Scope the allowlist check to the
matched secret-candidate substring only, which the per-match re-check
already did downstream but the whole-line pre-check short-circuited
before it ever ran.

Extend validate-secrets.bats with a case proving a real AWS-style key is
still caught when a placeholder token sits elsewhere on the line.
Regenerate the flat-mirror copy at
plugins/core/skills/agentsmd-audit/scripts/validate-secrets.sh via
scripts/sync-plugin-content.sh --all per ADR-0016.
hook_file_regexes() unioned the `files:` regex from .pre-commit-hooks.yaml
and .pre-commit-config.yaml before checking whether a probe path is in
scope of a kyberforge vale-audit-prefilter hook. That union let a probe
matching only the old, looser .pre-commit-hooks.yaml pattern pass even
after .pre-commit-config.yaml's copy of the same hook had been narrowed
(e.g. to require a `.agent.md` suffix) -- silently masking exactly the
kind of hook-rescoping drift this check exists to catch. Per ADR-0014
the two manifests are meant to exercise the same resolution path an
external consumer's hook would, so this divergence is real drift, not
noise.

hook_file_regexes() now takes the manifest path explicitly and caches
per (skill, manifest) pair instead of per skill, so each manifest's
regex set can be inspected on its own. The probe-validation loop
computes in_hooks/in_config independently via a new matches_any_regex()
helper. Probes carry a new third heredoc field, `shared` or
`hooks-only`: `shared` probes (a file shape genuinely covered by both
manifests, e.g. plugins/demo/.apm/agents/demo.agent.md) must agree
between the two or the check now fails with a drift error;
`hooks-only` probes (a Copilot .agent.md living outside this repo's
own plugins/.apm/ layout, and the legacy bare-`.md`-under-agents/ shape
kept only to exercise a distinct .vale.ini glob section in isolation)
are exempt, since .pre-commit-hooks.yaml is deliberately broader there
by design. The original "matches no regex in either manifest"
staleness check is unchanged.

Added case 11b to tests/test-check-vale-style-sync.sh: narrows a
fixture's local config regex further while leaving
.pre-commit-hooks.yaml untouched, and asserts the check now flags it.
Confirmed red against the pre-fix script before applying the fix.

Refs: #95
--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
Skills/hooks/mcpServers/agents pointer-field validation in plugin.json was
fully delegated to sync-plugin-content.sh --check, but that script explicitly
skips any plugin directory lacking .apm/ (it has nothing to compile there).
A plugin with no .apm/ and a hand-authored plugin.json whose pointer field
points at a missing path was therefore left uncovered by either check --
currently latent since every plugin in this repo has .apm/, but a real gap
for the first non-apm plugin added.

Restores a fallback validation path here for exactly that case (no .apm/
directory), reusing the pre-delegation logic this script used to run
unconditionally. apm-native plugins keep relying on the delegated check so
the two never duplicate (or disagree) on the same manifest.

Also switches the marketplace.json walk to the shared
scripts/lib/marketplace-plugins.sh helper introduced alongside
sync-plugin-content.sh's matching --all branch, replacing the
near-identical hand-duplicated loop this script's own header comment
already flagged as a duplication risk.

Adds fixtures: a non-apm plugin with a broken skills pointer (caught), a
non-apm plugin with a valid pointer (no false positive), and an apm-native
plugin with a broken pointer (left to the delegated check, not
double-validated here).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01X7GvKuJfy2WrdBmUttV4DT
Review findings #5 and #7 on PR #95 flagged two bash-3.2-incompatible
patterns despite the surrounding scripts claiming 3.2 safety:

- tests/run-bats.sh used `mapfile` (bash 4.0+), which fails immediately
  under macOS's stock bash 3.2 before any batching logic runs. Replaced
  with the `while read` loop already established in tests/run-tests.sh,
  and guarded the two downstream `${TEST_FILES[@]}` expansions with
  `${arr[@]+"${arr[@]}"}` to match that file's convention.

- `trap 'rm -rf "${CLEANUP_DIRS[@]}"' EXIT` was unguarded in
  tests/test-sync-marketplace-mirror.sh and
  tests/test-sync-plugin-content.sh: under `set -u`, if `mktemp -d`
  fails before the array is populated, the trap itself throws an
  unbound-variable error that masks the real test failure. A repo-wide
  grep for the same pattern turned up a third, unreviewed instance in
  tests/test-check-release-needed.sh. Fixed all three with the guarded
  idiom already used elsewhere in the repo.

Extended the existing bash-3.2-hazard static check (test 16 in
tests/test-vale-wrap.sh) to scan all four fixed files going forward,
so a regression of either pattern fails the suite instead of only
surfacing on a real bash 3.2 host.

Refs: PR #95
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01X7GvKuJfy2WrdBmUttV4DT
Defame1297 added 5 commits 2026-08-14 06:59:17 +00:00
`check-plugin-content-sync` was the only local pre-push hook scoped with `files:`
instead of `always_run`, and its regex missed both compiled `plugin.json` paths —
exactly the manifests the script had been taught to diff. A commit tampering only
with one of those passed the entire pre-push suite at exit 0.

A regex fix alone could not close it: pre-commit drops deleted paths from the file
list, so removing a mirror directory skipped the hook too. The `hooks\.json`
alternative was also already dead, matching only the pre-move flat path. Dropping
`files:` for `always_run: true` closes all three and makes the hook uniform with its
three siblings; the check costs ~2-4s.

Also excludes `.github/plugin/marketplace.json` from `pretty-format-json`. This was
not hypothetical: `json.dumps(indent=2, sort_keys=True)` reproduces the manifests
committed at HEAD byte-for-byte, so `--autofix` had already rewritten compiled apm
output once. Without the exclusion it would do so again on the next commit,
silently breaking the byte-identical mirror invariant.

Landing first, ahead of the regenerated manifests, so that cannot happen.

Refs: #90

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
Four repo gates reported success in states they exist to reject.

`check-vale-style-sync.sh` passed while a Kyberforge lint rule was silenced. The
check matched a blocklist of severity values, but Vale's semantic is an allowlist:
anything that is not exactly YES/error/warning/suggestion disables the rule. So
`= false`, `= 0`, `= garbage`, an empty value and — worst — a lowercase `= yes` all
killed enforcement while reading as "enabled" to a human. Inverted to an allowlist.
Two sibling holes: dropping `KyberforgeCopilot` from `BasedOnStyles` unloaded the
Copilot-only check silently, and narrowing a section glob to a location made Vale
lint zero files, which is the "0 files, hook Passed" failure the script's own
comment says it exists to catch.

`sync-marketplace-mirror.sh --check` failed open when its source was missing, while
its sibling correctly errored in the same state.

`check-scope-walkup-sync.sh` wrote to hardcoded `/tmp/fN.out` paths and read one
back, making it non-reentrant — a concurrent instance can flip a verdict, and this
branch made the test runner concurrent. Now per-run `mktemp -d`.

`check-manifests.sh` had no disk-to-marketplace pass, so a plugin directory absent
from `marketplace.json` passed every gate while the `validate-plugins` hook globbed
it. The "listed" match is restricted to remote-source entry names; matching any
entry name let a genuine orphan through on a name coincidence.

`run-bats.sh` reported an empty TAP stream as `0 tests, 0 failures`, exit 0 — a
total harness failure reading as a pass.

The test-side changes are the larger half, because the guards were the real problem.
`test-sync-marketplace-mirror.sh` could overwrite the live tracked mirror under an
inherited GIT_DIR, which is precisely the git-hook context it runs in. The bash-3.2
scan hand-maintained its file list, omitting the new shared runner, and had no rule
for `wait -n` or `nproc` — the two hazards the previous review round found live. It
now derives 43 files across three globs with per-glob floors. Several assertions
were decoration: the concurrency checks caught the reentrancy defect 0 times in 10,
the leak fix was green either way, and two manifest fixtures passed with the code
they claimed to cover deleted. Every assertion now has a revert it provably fails
against.

Refs: #90

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01X7GvKuJfy2WrdBmUttV4DT
`core`'s description and keywords described `bin`'s skills — triage, diagnosis,
architecture review — while `core` ships the three agentsmd/provider-adapter skills.
Pre-existing text, but this PR compiles it into `plugin.json` and both
`marketplace.json` files, so it became published metadata. The root `apm.yml` entry
carried the same stale text and is the one that actually reaches the marketplace
manifests, so fixing only the package manifest left it half-propagated.

Version bumps follow the per_package policy this PR documents, each landing in both
the package `apm.yml` and the root entry: kyberforge 1.4.0 -> 1.4.1 for the
reference-doc changes, core 1.1.0 -> 1.1.1 for the metadata, catalog 0.3.2 -> 0.3.3
as a marketplace-block-only edit.

The compiled diff is larger than those four changes because the manifests committed
at HEAD were not apm output: `json.dumps(indent=2, sort_keys=True)` reproduces all
three byte-for-byte, i.e. `pretty-format-json --autofix` had rewritten them before
the exclusion existed. Regenerating realigns them to genuine `apm pack` output;
the remainder of the diff is key ordering and UTF-8 escaping. `core`'s keyword
change is a fifth semantic change, invisible at marketplace level because
`marketplace.json` carries no keywords field.

Refs: #90
ADR: 0015

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01X7GvKuJfy2WrdBmUttV4DT
Documentation asserted things the code no longer did, which is how several of this
round's defects survived three review passes.

- `docs/spec/architecture.md` still described the pre-APM model — "each plugin has a
  `plugin.json` manifest" — when no such file exists. AGENTS.md routes agents here
  for the current structure, so it was live drift, not archive. Also cited ADR-0012
  where ADR-0003 is meant.
- AGENTS.md never said `.apm/` is the sole hand-edited source, while the generated
  mirror is byte-identical with no marker — an agent reading only AGENTS.md would
  edit the mirror and find out at pre-push. It also omitted that `apm` and `jq` are
  now required to push, unlike the `vale` bullet that exists for exactly that reason,
  and understated the pre-push gate. `apm marketplace check` hard-fails offline and
  `--offline` does not help, so the `SKIP=` escape hatch is documented rather than
  new machinery built.
- ADR-0017 carried hardcoded line numbers that drifted twice within one PR, and said
  `mcpServers` reinjection was real-syncs-only where the script's own header says
  both modes. Line numbers dropped rather than corrected.
- ADR-0015 asserted issue #90 was closed. It is open. Reworded to defer to the
  issue's own state rather than depend on someone remembering to close it.
- ADR-0014 said both audit skills pass `--config`; both SKILL.md files say pass none,
  and passing one fails with E100. The ADR was wrong.
- ADR-0007 cross-referenced an archived ADR-0011 whose number was later reused.
  Disambiguated with a note — renumbering a published ADR rewrites an immutable
  record and breaks every existing citation.
- CONTEXT.md claimed `lint-runner` is report-only via a missing `Edit` tool.
  Plugin-scope APM agents cannot express `tools:` (ADR-0016), so it is report-only by
  instruction now; ADR-0016 accepted that regression but CONTEXT.md was not updated.
- Recorded two undocumented consequences of the conversion: `displayName` dropped
  from all six compiled manifests and `owner.email` from the marketplace, and
  `mattpocock-skills` silently version-pinned and manually maintained.
- Wrote the version-bump policy issue #90 specified but never delivered. Its previous
  carrier, `marketplace-author`, was deleted in this PR, leaving the per-package rule
  documented nowhere.
- README fixes: kyberforge listed skills belonging to the `git` plugin and a deleted
  hooks path, `bin` advertised an agents directory it never had, and the docs index
  listed files that do not exist.

Refs: #90

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01X7GvKuJfy2WrdBmUttV4DT
Defame1297 added 1 commit 2026-08-14 07:11:46 +00:00
The first is new: four fixes specified by the orchestrating reviewer were wrong,
each in a way that would have shipped a guard catching nothing. A finding arrives
with evidence and gets scrutinised; the fix beside it arrives with the same
authority and gets implemented. Worth naming because the failure is silent — the
guard looks right and the suite stays green.

The second is flagged as a graduation candidate rather than promoted: it overlaps
two 2026-08-09 entries on assertions that pin something adjacent to what they
name. Per the graduation rule, promotion to core/instructions/testing.md is the
human's call, not mine.

Refs: #95

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01X7GvKuJfy2WrdBmUttV4DT
Defame1297 added 5 commits 2026-08-14 09:27:00 +00:00
`parts` is seeded empty and all seven appends are conditional, so
"${parts[@]}" at the join loop can expand an empty array. install.sh
deploys this file to every user machine.

Two things had to both hold for the bare form to be safe: this file
enabling no `set -u`, and the shell being bash 4.4+, which stopped
treating an empty-array expansion as unbound. On bash 3.2 -- macOS's
system bash, an explicit repo target -- adding `set -u` aborts here.
That is also why the hazard is unreproducible on a modern dev box and
why the enforcement is a static scan rather than a runtime test.

Adds the `providers` glob to test-vale-wrap.sh's bash-3.2 scan, which
excluded it precisely because of this defect. Floor is 1 rather than
"count minus slack": the glob holds one file, so any slack at all
means a floor of 0, which passes vacuously on a renamed directory.

Also adds case 27, the regression test for the stale `shellcheck
source=` directives fixed in the next commit (#97 item 2). It lives in
this file because that is where the exemption it guards lives.

Closes #96
Refs #97

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01X7GvKuJfy2WrdBmUttV4DT
`tests/run-tests.sh` declared `source=lib/batch-run.sh`, which resolves
to neither the repo root nor the script's own directory. A directive
that does not resolve is silent: it blinds test-vale-wrap.sh's
`sourced_files()` seeding exemption, and shellcheck's own SC1091 is
`info` while .pre-commit-config.yaml pins `--severity=warning`.

Issue #97 names run-bats.sh's `../scripts/lib/batch-run.sh` as the
correct spelling. It is not. Directives resolve against the source-path,
which under pre-commit is the repo root, so `../scripts/...` escapes the
repo and trips SC1091 exactly as `lib/...` does -- verified directly.
The spelling satisfying both shellcheck and `sourced_files()`'s
two-candidate rule is repo-root-relative, matching scripts/install.sh.

Fixes all three: run-tests.sh, run-bats.sh, and check-manifests.sh,
the last unmentioned by the issue. Every directive in the repo now
resolves, which the previous commit's case 27 asserts.

Refs #97

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01X7GvKuJfy2WrdBmUttV4DT
`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
Issue #97 item 1 reports the unreadable-.vale.ini guard as untested. It
was worse: it was dead. `[[ -r ]]` is access(2), which asks whether the
permission bits would allow a read -- and for uid 0 that is yes even on
a mode-000 file. This hook runs at pre-push and the dev environment is
root, so the guard could never fire where it exists to fire. That is
why no uid-independent test for it existed; there was nothing to test.

Readability is now decided by actually reading (`cat`), which is
uid-independent and strictly stronger, catching EISDIR and EIO that
access(2) reports on neither. `cat`, not a `< "$ini"` redirect: opening
a directory for reading succeeds, only the read fails. The missing
branch moves to `-e`, so a directory sitting where the file belongs is
reported as unreadable rather than sending the reader hunting for a
deleted file.

The new case asserts the MESSAGE, not the exit code. With the guard
removed the script still exits 1 -- the greps hit the unreadable path
and blame a missing StylesPath on a file that has one. An exit-code-only
test would have been green with the guard deleted.

Also stops paying for vale in cases that only assert .vale.ini text:
21 of 28 script runs now mask it via the PATH_NO_VALE mechanism case 12
already builds, cutting the suite's bottleneck ~3.5x (issue #97 item 5).
The helper falls back to an unmasked run rather than skipping, so a
machine where masking is unavailable loses speed, never coverage.

That masking is a coverage gain, not only a speedup. With vale on PATH,
cases 8 and 9 could not detect deletion of the assertions they were
written to catch: a dropped StylesPath also breaks the glob probe, so
the script exited 1 for the wrong reason and both cases went green.
Verified against the pre-change files -- the same mutation was caught by
one incidental assertion before, and by three after.

Refs #97

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01X7GvKuJfy2WrdBmUttV4DT
AGENTS.md said 12 pre-push hooks and recommended a command that reports
14, so a reader following the instruction hit a mismatch on the first
try. The repo defines 12; pre-commit's own `meta` hooks,
check-hooks-apply and check-useless-excludes, declare no `stages:` and
therefore also run at pre-push.

Refs #97

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01X7GvKuJfy2WrdBmUttV4DT
Defame1297 added 15 commits 2026-08-14 14:30:48 +00:00
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
A marketplace entry missing its source key disabled both directions of the
check at once. The helper required source to be a string, so a source-less
entry was skipped and its plugin.json existence check never ran; the name axis
selected on (.source | type) != "string", and null != "string" is true, so the
same entry also marked its on-disk directory as listed. Delete source from an
entry and delete its plugin.json and the script exited 0. Because
sync-plugin-content.sh --all derives its work list from the same helper, that
plugin silently dropped out of the content-mirror gate too.

Also in this pass:
- a wrongly typed skills value crashed the script mid-loop with a raw jq error
  and no "Manifest check failed:" line, leaving every later plugin unchecked.
  Note skills is legally string|string[] per both host schemas, so a string
  now resolves as a single path rather than erroring
- array- and object-valued pointer fields were reported missing even when they
  resolved, because the whole JSON value was pretty-printed into a path test
- an unparseable marketplace.json died inside a process substitution, so the
  run reported six "no entry in marketplace.json" errors that sent the reader
  to edit apm.yml when the real fault was a corrupt manifest
- a missing marketplace.json exited 0 even with plugin directories present

Tests: 14 -> 23 assertions. Every failure case asserts on message text, not
exit code alone, since exit 1 here is reachable by several causes that call
for opposite fixes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01X7GvKuJfy2WrdBmUttV4DT
check-vale-style-sync.sh's glob-coverage probe silently self-disabled when
vale was absent from PATH, exiting 0 on the one-character glob typo it exists
to catch. pre-commit swallows a passing hook's output, so the pre-push hook
reported Passed. The script already hard-fails on a bad REPO_ROOT for exactly
this reason -- "a clean exit 0 here would read as 'checked, in sync' when
nothing ran at all" -- and six of its assertions are vale invocations. Absence
now fails; the opt-out is an env var that must be set deliberately, and it
downgrades the run to text-level assertions while saying so in the summary.

Neither script had a floor on its rewritten .apm/ paths, so relocating .apm/
made both exit 0 -- and this PR's whole change to them was a path rewrite,
the exact edit that failure mode survives. A third gap the directory check
could not see: relocating only assets/vale/ left both audit skill directories
in place while every probe continued past its missing .vale.ini, skipping the
whole table with FAIL=0. A zero-probe run is now an error.

Both test suites encoded the vacuous pass as a passing case. Those cases are
now scoped to "no plugins/kyberforge at all" and assert the fixture really
lacks it, with new counterparts covering the drift shape and new positive
cases requiring each script to report a non-zero inspected-target count.

Also removes the HOOK_REGEX_CACHE memoization: every call site was a command
substitution, so the writes happened in a subshell and the lookup always
missed. Measured at 14ms of an ~870ms run, all of which is the six vale
invocations. Deleted rather than repaired -- 35 lines claiming a benefit they
never delivered is worse than no cache -- with a comment recording why.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01X7GvKuJfy2WrdBmUttV4DT
run_bats() was an if with no else, so a missing or non-executable run-bats.sh
made all 166 bats tests disappear with no diagnostic while the run printed a
green summary and exited 0. --bats-only became a total no-op. This is the same
defect the PR fixed one level down, left open in the dispatcher that pre-push
actually invokes -- and nothing tested run-tests.sh at all.

run-bats.sh's aggregation was asserted by nothing. Three separate mutations to
its failure-detection line all survived the existing suite, because real bats
emits both a nonzero exit and "not ok" lines, so each signal masked the other.
The new cases produce each signal without the other; all three mutants now die.

Also in this pass:
- zero discovered .bats files exited 0, so a widened path exclusion retired the
  suite silently. Replaced with a file-count floor: a collapse to one or two
  files is the same failure as a collapse to zero
- an existing-but-empty status file counted as a pass, because [[ "" -eq 0 ]]
  is arithmetic-true and the || echo 1 fallback only covered a missing file.
  The repro is deterministic: the stub truncates its own status file, then
  kill -9s its parent so the real exit-code write never happens

Adds tests/test-run-tests.sh, 9 cases pinning the exit 0 / 1 / 77 three-way
split against a single summary line.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01X7GvKuJfy2WrdBmUttV4DT
mattpocock-skills was pinned as the range ^1.2.0 with no lockfile, so apm
re-resolved it against upstream on every pack. An upstream v1.2.4 would
invalidate the committed ref/sha and fail apm-pack-check-clean with exit 4,
blocking every push in the repo at an unrelated moment, triggered by a third
party. ADR-0015 claimed the opposite -- that nothing advances it. Pinned to
1.2.3, which resolves to the already-committed sha, so the only compiled
change is the version key the remote entry alone was missing.

marketplace.owner.email was dropped on a false premise: ADR-0015 said apm has
no key for it, but yml_schema.py defines _AUTHOR_OBJECT_KEYS as {name, email,
url} and the key compiles through. Restored. (displayName is genuinely
unsupported and stays dropped.)

ADR-0016 dropped per-agent tools: because the allowlist shape is unportable --
Claude takes a comma list, Copilot a {Tool: true} map. That holds. But a
denylist has no such conflict: disallowedTools is honoured by Claude Code and
is absent from its plugin-subagent ignore list, and Copilot copies agent
frontmatter verbatim so an unknown key is inert. gitea-orchestrate,
apm-orchestrate and lint-runner were all write-denied on main and lost that
fence silently; only lint-runner's loss was disclosed, and only lint-runner
had prose to fall back on. All three regain the fence, and the two with no
no-edit language gain three statements each. git-orchestrate is untouched --
it legitimately had edit.

Four plugins shipped changed compiled output under unchanged versions,
against the policy this PR itself wrote: bin 1.1.1->1.1.2, git 1.3.2->1.3.3,
gitea 1.3.3->1.3.4, lint 1.1.5->1.1.6, each in both the plugin manifest and
the root packages[] entry. Root catalog 0.3.3->0.3.4: patch, because the set
of entries is unchanged and what moved is the owner block and four versions.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01X7GvKuJfy2WrdBmUttV4DT
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
Output of apm pack, sync-plugin-content.sh --all and
sync-marketplace-mirror.sh against this round's source changes. No file here
is hand-edited.

Carries the version bumps and marketplace owner.email into the compiled
manifests, the disallowedTools frontmatter and doc corrections into the flat
mirrors, and changes plugins/bin/.github/plugin/plugin.json's mcpServers from
the inlined server object to the ".mcp.json" pointer. That last file also
returns to 0644: the previous re-injection wrote it through mktemp and carried
0600 across, which no gate could see because the mode check did not cover
.github/plugin/ and git tracks only the exec bit.

.agents/plugins/marketplace.json is unchanged and that is correct -- apm's
codex profile carries neither version nor owner keys, so nothing in this round
reaches it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01X7GvKuJfy2WrdBmUttV4DT
Three gaps left by the previous round, all the same shape: a gate reporting
success having verified less than it appears to.

run_bats() hard-failed on a missing or non-executable runner but never checked
that the runner produced anything. An empty, executable run-bats.sh exits 0,
and the dispatcher printed a green summary with 166 bats tests silently absent.
It now requires an "N tests, M failures" line with a non-zero count.

run-tests.sh's skip listing is swallowed by pre-commit on a pass, so on a
machine without vale three suites exited 77 and the pre-push gate went green
having run 14 of 17. The hook now invokes it as --strict, where a skip fails
and the error names each suite and the reason it skipped. An ad-hoc local run
still skips gracefully -- at pre-push a skip means a documented dependency is
missing, which is a setup error, not a legitimate state. Deliberately not wired
to the vale downgrade's env var: one flag must not disarm two gates.

BATS_FILE_FLOOR is replaced by an expectation derived from git ls-files. A
floor of 8 against a real count of 10 let two files and eleven tests disappear
green, and the number needed an edit whenever a plugin was added. The derived
set needs no number, and catches an addition as well as a removal -- a .bats
file staged into the index and deleted from disk is now demanded back.

The vale opt-out announced its downgrade to nobody: pre-commit prints nothing
for a passing hook, so the summary line AGENTS.md tells the reader to check was
unreachable in exactly the situation it exists for. The hook is now verbose.

Also corrects the PROBES_CHECKED guard, whose commit message described a state
that cannot occur -- the .vale.ini loop errs first. Its two reachable triggers,
a gutted probe heredoc and a probe row naming a missing directory, had no test;
they do now, each asserting the guard is the sole cause.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01X7GvKuJfy2WrdBmUttV4DT
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
The previous round taught agent-audit's validator to permit disallowedTools but
left the skill that writes agents still forbidding it, in six places. Running
agent-author on any of the three fenced orchestrators would have stripped the
fence, and nothing would have caught it: the validator's allowlist is a permit
list, so an absent field passes. The template was the worst of them, since its
comment is copied verbatim into every new plugin-scope agent.

Where a list had to be restated it is now a pointer to field-inventory.md's
apm-agent-allowlist instead -- the same data validate.sh reads -- because a
roster copied into a template goes stale one step further out than the roster
itself. Where the text has to teach something it teaches the shape rule rather
than the exception: tools is an allowlist whose vocabulary differs per harness,
so verbatim copy makes one value wrong on one target; disallowedTools is a
denylist, where an unrecognised name denies nothing, so the worst case is a
missing fence rather than a wrongly granted capability.

ADR-0016's amendment claimed an unrecognised key is inert on Copilot while the
same ADR's Context says that behaviour is unconfirmed by research -- asserting
as settled the exact thing it flags as unknown, and justifying it with apm's
compile-time behaviour, which says nothing about Copilot's runtime. It is
rewritten into labelled tiers: confirmed for Claude Code with citations,
inferred by analogy for Copilot with the analogy's limits stated, unverified
where it is unverified, and the residual risk accepted explicitly with its
blast radius. It also no longer claims to restore a write sandbox: the denylist
does not deny Bash, which these agents inherit and legitimately need.

docs/hooks.md called the old root hooks.json a stale sync artifact -- it was
added in the plugin's creating commit and pointed at by main's Copilot manifest
-- and claimed both ecosystems now resolve hooks/hooks.json. Copilot does not:
its hooks field has no default and no compiled manifest declares one, so it
resolves nothing. Recorded as the gap it is, with re-injection noted as a
follow-up rather than asserted away. Its event list is marked partial.

Also: new-agent.bats asserted a hardcoded four-field allowlist and would have
rejected a scaffolded agent carrying the field the ADR now blesses; it reads
field-inventory.md too. And ADR-0016's premise that Claude's tools: is
space-separated was wrong -- it takes a comma-separated string or a YAML list.
The incompatibility with Copilot is the vocabulary, not the punctuation.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01X7GvKuJfy2WrdBmUttV4DT
Output of scripts/sync-plugin-content.sh --all against this round's .apm/
source edits. No file here is hand-edited.

Carries the agent-author and agent-audit documentation changes into the flat
mirrors. No compiled manifest changed: nothing in this round touched apm.yml,
so apm pack and sync-marketplace-mirror.sh both produced byte-identical output.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01X7GvKuJfy2WrdBmUttV4DT
apm-audit-ci ran against root apm.yml alone, so it audited none of the six
plugin packages, and its description claimed a lockfile/policy/hidden-content
gate while delivering one vacuous check. It now loops all seven manifests, and
the description says only what runs. Proven load-bearing: a malformed dependency
in plugins/lint/apm.yml passed the old root-only entry at exit 0 and passed
apm pack --check-clean too, because that gate never parses plugin dependencies;
the loop catches it and names the file.

policy.fetch_failure_default: block was considered and rejected. apm's org-policy
discovery understands github.com and Azure DevOps; this repo's remote is
self-hosted Gitea, so no policy source is discoverable and the setting makes the
hook exit 1 on every push forever. Fail-closed is right when there is a control
to fail closed on -- a permanently red gate is one people learn to SKIP=, which
is worse than an accurate description.

agent-audit's validate.sh had never run against the four real .apm/agents files
it governs, only against synthetic fixtures. That is why an amended ADR-0016 and
a validator that still rejected the field it blessed could disagree unnoticed
until someone ran it by hand. check-apm-agents-valid.sh closes it, deriving the
expected set from git ls-files rather than a count, failing on zero discovered
files, and replaying validate.sh's own reason under each failing filename.

Also makes the pretty-format-json exclude consistently root-anchored: it mixed
(^|/) for five paths with ^ for one, so a nested fixture at
.../.claude-plugin/plugin.json was silently exempt from formatting.

Pre-push goes 12 repo-defined to 13, 14 total to 15; AGENTS.md's counts, hook
list and offline-skip note are updated to match. The new hook needs no network.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01X7GvKuJfy2WrdBmUttV4DT
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
batch_run ended with a bare wait, which blocks on every background job the
calling shell has, not the ones it started. Harmless for all three current
callers, but a future caller that backgrounds anything of its own would have
batch_run block on it or consume its status. It now records each $! and reaps
exactly those PIDs.

The `wait "$pid" || true` there is load-bearing: unlike a bare wait, wait <pid>
returns the job's status, so without it a single failing job would abort the
set -e caller at the call site -- before run-tests.sh or sync-plugin-content.sh
could read their .status files and print a summary. Status semantics stay in
those files, exactly as before.

check-vale-style-sync.sh's glob probe discarded vale's exit code and output and
decided purely on a grep, so a failed exec, an OOM-killed vale or a full TMPDIR
was indistinguishable from a real glob defect -- both printed "its glob sections
do not cover a path" with no evidence. A flake seen once in this probe could not
be diagnosed afterwards for that reason. The probe now attaches vale's rc and
output: a genuine glob defect reads "vale exited 0 ... in 0 files", a killed vale
reads "vale exited 137; output: <empty>".

That flake was investigated and not reproduced -- 1680 probes across three
contention setups including an offline namespace, all clean -- so nothing is
changed speculatively. The misattribution is worth recording: it was reported
against tests/test-vale-wrap.sh, which never invokes this script; the assertion
belongs to check-vale-style-sync.sh and reaches a log through a different suite.

Also drops the last stale field roster from agent-author's scaffolder. Its
next-steps hint enumerated "(name, description, model, body only)" -- omitting
disallowedTools, and never accurate anyway, since the template marks only
description and the body FILL IN. Its --help carried the inverted form, already
missing six forbidden fields. Both now state the shape rule and point at
field-inventory.md, and a bats case enforces all-or-nothing: name every
allowlisted field or name none, since a partial roster is the shape that goes
stale silently.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01X7GvKuJfy2WrdBmUttV4DT
Output of scripts/sync-plugin-content.sh --all against this round's .apm/
source edits. No file here is hand-edited.

Carries agent-author's scaffolder and its contract doc into the flat mirror.
No compiled manifest changed: nothing in this round touched apm.yml, so apm
pack and sync-marketplace-mirror.sh both produced byte-identical output.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01X7GvKuJfy2WrdBmUttV4DT
Author
Collaborator

Fifth review round: 15 commits (0f0ac58..a4a075b)

A multi-agent review ran against this branch, then an independent second round reviewed the fixes with agents that had not written them. That second round is where most of the value was — it found a regression the first round introduced, and several fixes that were narrower than their own commit messages claimed.

All 15 pre-push hooks pass. run-tests now runs in --strict mode, so that result means 18/18 suites actually executed rather than silently skipping.


The security defect

reinject_mcp_servers copied .mcp.json's mcpServers verbatim into the compiled Copilot manifest, bypassing apm's _sanitize_mcp_servers() — which exists because copying that file's contents into a committed manifest exfiltrates credentials into the distributed artefact. Proven: adding "env": {"TOKEN": "…"} to plugins/bin/.mcp.json produced a stripped Claude manifest and a Copilot manifest carrying the live token. No gate caught it, because check-plugin-content-sync compared the re-injected output against itself.

Now re-injects the string pointer ".mcp.json". Both host schemas type the field string | object, so the host resolves it at load time and no resolved content — sanitized or otherwise — enters the manifest. This also preserves the ${VAR} indirection apm's sanitizer would strip.

A regression this round introduced, then fixed

Widening the mirror's mode check from the exec bit to full permissions made check-plugin-content-sync fail at pre-push on a pristine tree for anyone on a umask-002 distro — and unfixable by committing, since git records no non-exec mode. hooks/hooks.json is written by the sync at the runtime umask, not copied from the bundle.

Resolved with a rule rather than a patch: record a mode for a path the pipeline copies, never for one it writes. Verified on clones checked out at umask 022, 002 and 077.

Gates that passed while verifying nothing

Nine, all confirmed by constructing the failure condition and watching the gate exit 0:

Gate Passed when…
sync-plugin-content --check --all the marketplace yielded zero plugins — including on unparseable JSON, so the one gate whose work list comes from a generated file could be silenced by regenerating its own input
check-manifests an entry lacked source (null != "string" is true, so both passes skipped it at once), or the manifest was missing entirely
check-vale-style-sync vale was off PATH — the probe self-disabled on the exact one-character glob typo it exists to catch
check-scope-walkup-sync .apm/ was relocated (no floor existed, contrary to an earlier claim that one did)
run-tests.sh run-bats.sh was missing, non-executable, or empty — 166 bats tests vanished with a green summary
run-bats.sh zero .bats files were discovered, or a status file was empty ([[ "" -eq 0 ]] is arithmetic-true)
run-tests (pre-push) three suites skipped for a missing vale — the gate reported success having run 14 of 17
apm-audit-ci a plugin manifest was malformed — it only ever audited the root
--all fewer plugins were verified than the marketplace lists

run-bats.sh's failure-detection line survived three separate mutations because real bats emits both a nonzero exit and not ok lines, so each signal masked the other. The new stubs emit each without the other.

An operational time bomb

mattpocock-skills was pinned as the range ^1.2.0 with no lockfile, re-resolved on every apm pack. An upstream v1.2.4 would have invalidated the committed ref/sha and failed apm-pack-check-clean with exit 4 — blocking every push in the repo, triggered by a third party, at an unrelated moment. ADR-0015 claimed the opposite ("nothing advances it"). Now pinned exactly.

Silently dropped safety restrictions

gitea-orchestrate, apm-orchestrate and lint-runner were all write-denied on main and lost that fence in the conversion. Only lint-runner's loss was disclosed, and only lint-runner had prose to fall back on.

ADR-0016 dropped tools: because the allowlist shape is unportable — Claude takes a comma list, Copilot a {Tool: true} map. That reasoning holds. A denylist has no such conflict, so the fence is restored via disallowedTools, plus explicit no-edit prose on the two agents that had none. git-orchestrate is untouched — it legitimately had edit.

Reconciling that took six more files than expected: agent-author would have stripped the fence it had just been given, and the validator's permit-list cannot catch an absent field. Rosters are now pointers to field-inventory.md — the same data the validator reads — and a bats case enforces all-or-nothing, since a partial roster is the shape that goes stale silently.


Two decisions to not fix

The Copilot hooks gap is documented, not patched. 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 the pointer 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 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 this, so restoring the pointer fails until someone confronts the schema mismatch.

policy.fetch_failure_default: block was rejected on evidence. apm's org-policy discovery understands github.com and Azure DevOps; this repo's remote is self-hosted Gitea, so no policy source is discoverable and the setting makes the hook exit 1 on every push forever. Fail-closed is right when there is a control to fail closed on — a permanently red gate is one people learn to SKIP=.

One flake, investigated and not fixed

A probe failure reproduced once under parallel load. 1680 probes across three contention setups, including an offline namespace, came back clean. It was also misattributed — reported against tests/test-vale-wrap.sh, which never invokes the script that emits it; the assertion belongs to check-vale-style-sync.sh and reaches a log through a different suite.

No speculative fix was made to a pre-push gate. Instead the reason it was undiagnosable is fixed: the probe discarded vale's exit code and output, so a failed exec, an OOM-killed vale or a full TMPDIR was indistinguishable from a real glob defect. The message now separates vale exited 0 … in 0 files (genuine glob defect) from vale exited 137; output: <empty> (vale died).


Corrections to this PR's own prior claims

  • The .vale.ini readability fix is not uid-independent — as root, cat on a mode-000 file still succeeds. The fix is still an improvement (failure attribution is fixed and mutation-verified), but c442f7e's stated rationale was wrong.
  • ADR-0017 said apm strips a hooks field. The real list is (agents, skills, commands, instructions) — hooks absent, instructions unmentioned — and the loop can never fire, because synthesize_plugin_json_from_apm_yml emits only eight identity fields.
  • owner.email was dropped on a false premise; yml_schema.py accepts it. Restored.
  • Four plugins shipped changed compiled output under unchanged versions, against the policy this PR itself wrote. Bumped.
  • AGENTS.md's offline-push instruction did not work: apm-pack-check-clean also needs the network, and the same paragraph forbade adding anything else to SKIP.
  • A commit rationale described a defect that cannot occur (the PROBES_CHECKED guard); the guard's two reachable triggers had no test and now do.

New gates

  • check-apm-agents-valid — agent-audit's validator had never run against the four real agent files it governs, only synthetic fixtures. That is precisely why an amended ADR and a validator that rejected the field it blessed could disagree unnoticed.
  • run-bats.sh derives its expected .bats set from git ls-files instead of a hardcoded floor — the old floor of 8 against a real 10 let two files and eleven tests disappear green.

Pre-push: 12 → 13 repo-defined hooks (15 total). AGENTS.md's counts, hook list and offline note updated to match.

Verification

Every behavioural fix is mutation-verified — test written, fix reverted, test observed RED, fix reapplied, GREEN. A test nobody watched fail was not counted.

  • bash tests/run-tests.sh --strict → 18 passed, 0 skipped, 0 failed; 166 bats tests
  • All 15 pre-push hooks pass
  • sync-plugin-content --check --all exits 0 under umask 022, 002 and 077
  • shellcheck -x --severity=warning clean across all changed shell files
  • Compiled output byte-reproducible: apm pack → both syncs → empty git status

Known-open, deliberately out of scope

Three doc defects that pre-date this PR: ADR-0011's stale plugins/bin/skills/gitea/ reference, LESSONS.md's citations of scripts/gitleaks.toml and tests/test-plugin-validate.sh (neither exists), and architecture.md:45 calling the enforcement layer "Phase 2 (planned)" while 15 hooks run today.

There is still no CI in this repo. Merging via the web UI runs nothing. Merge from a local push, or run pre-commit run --hook-stage pre-push --all-files first.

🤖 Generated with Claude Code

## Fifth review round: 15 commits (`0f0ac58..a4a075b`) A multi-agent review ran against this branch, then an **independent second round reviewed the fixes** with agents that had not written them. That second round is where most of the value was — it found a regression the first round introduced, and several fixes that were narrower than their own commit messages claimed. All 15 pre-push hooks pass. `run-tests` now runs in `--strict` mode, so that result means 18/18 suites actually executed rather than silently skipping. --- ### The security defect `reinject_mcp_servers` copied `.mcp.json`'s `mcpServers` verbatim into the compiled Copilot manifest, bypassing apm's `_sanitize_mcp_servers()` — which exists because copying that file's contents into a committed manifest exfiltrates credentials into the distributed artefact. Proven: adding `"env": {"TOKEN": "…"}` to `plugins/bin/.mcp.json` produced a *stripped* Claude manifest and a Copilot manifest carrying the live token. No gate caught it, because `check-plugin-content-sync` compared the re-injected output against itself. Now re-injects the **string pointer** `".mcp.json"`. Both host schemas type the field `string | object`, so the host resolves it at load time and no resolved content — sanitized or otherwise — enters the manifest. This also preserves the `${VAR}` indirection apm's sanitizer would strip. ### A regression this round introduced, then fixed Widening the mirror's mode check from the exec bit to full permissions made `check-plugin-content-sync` **fail at pre-push on a pristine tree** for anyone on a umask-002 distro — and unfixable by committing, since git records no non-exec mode. `hooks/hooks.json` is *written* by the sync at the runtime umask, not copied from the bundle. Resolved with a rule rather than a patch: **record a mode for a path the pipeline copies, never for one it writes.** Verified on clones checked out at umask 022, 002 and 077. ### Gates that passed while verifying nothing Nine, all confirmed by constructing the failure condition and watching the gate exit 0: | Gate | Passed when… | |---|---| | `sync-plugin-content --check --all` | the marketplace yielded zero plugins — *including on unparseable JSON*, so the one gate whose work list comes from a generated file could be silenced by regenerating its own input | | `check-manifests` | an entry lacked `source` (`null != "string"` is true, so both passes skipped it at once), or the manifest was missing entirely | | `check-vale-style-sync` | `vale` was off PATH — the probe self-disabled on the exact one-character glob typo it exists to catch | | `check-scope-walkup-sync` | `.apm/` was relocated (no floor existed, contrary to an earlier claim that one did) | | `run-tests.sh` | `run-bats.sh` was missing, non-executable, **or empty** — 166 bats tests vanished with a green summary | | `run-bats.sh` | zero `.bats` files were discovered, or a status file was empty (`[[ "" -eq 0 ]]` is arithmetic-true) | | `run-tests` (pre-push) | three suites skipped for a missing `vale` — the gate reported success having run 14 of 17 | | `apm-audit-ci` | a plugin manifest was malformed — it only ever audited the root | | `--all` | fewer plugins were verified than the marketplace lists | `run-bats.sh`'s failure-detection line survived **three separate mutations** because real bats emits both a nonzero exit and `not ok` lines, so each signal masked the other. The new stubs emit each without the other. ### An operational time bomb `mattpocock-skills` was pinned as the range `^1.2.0` with no lockfile, re-resolved on every `apm pack`. An upstream `v1.2.4` would have invalidated the committed ref/sha and failed `apm-pack-check-clean` with exit 4 — **blocking every push in the repo**, triggered by a third party, at an unrelated moment. ADR-0015 claimed the opposite ("nothing advances it"). Now pinned exactly. ### Silently dropped safety restrictions `gitea-orchestrate`, `apm-orchestrate` and `lint-runner` were all write-denied on `main` and lost that fence in the conversion. Only `lint-runner`'s loss was disclosed, and only `lint-runner` had prose to fall back on. ADR-0016 dropped `tools:` because the allowlist shape is unportable — Claude takes a comma list, Copilot a `{Tool: true}` map. That reasoning holds. A **denylist** has no such conflict, so the fence is restored via `disallowedTools`, plus explicit no-edit prose on the two agents that had none. `git-orchestrate` is untouched — it legitimately had `edit`. Reconciling that took **six** more files than expected: `agent-author` would have stripped the fence it had just been given, and the validator's permit-list cannot catch an *absent* field. Rosters are now pointers to `field-inventory.md` — the same data the validator reads — and a bats case enforces all-or-nothing, since a partial roster is the shape that goes stale silently. --- ### Two decisions to *not* fix **The Copilot hooks gap is documented, not patched.** 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 the pointer 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 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 this**, so restoring the pointer fails until someone confronts the schema mismatch. **`policy.fetch_failure_default: block` was rejected on evidence.** apm's org-policy discovery understands github.com and Azure DevOps; this repo's remote is self-hosted Gitea, so no policy source is discoverable and the setting makes the hook exit 1 on every push forever. Fail-closed is right when there is a control to fail closed on — a permanently red gate is one people learn to `SKIP=`. ### One flake, investigated and not fixed A probe failure reproduced once under parallel load. 1680 probes across three contention setups, including an offline namespace, came back clean. It was also **misattributed** — reported against `tests/test-vale-wrap.sh`, which never invokes the script that emits it; the assertion belongs to `check-vale-style-sync.sh` and reaches a log through a different suite. No speculative fix was made to a pre-push gate. Instead the reason it was undiagnosable is fixed: the probe discarded vale's exit code and output, so a failed exec, an OOM-killed vale or a full `TMPDIR` was indistinguishable from a real glob defect. The message now separates `vale exited 0 … in 0 files` (genuine glob defect) from `vale exited 137; output: <empty>` (vale died). --- ### Corrections to this PR's own prior claims - The `.vale.ini` readability fix is **not** uid-independent — as root, `cat` on a mode-000 file still succeeds. The fix is still an improvement (failure *attribution* is fixed and mutation-verified), but `c442f7e`'s stated rationale was wrong. - ADR-0017 said apm strips a `hooks` field. The real list is `(agents, skills, commands, instructions)` — `hooks` absent, `instructions` unmentioned — and the loop can never fire, because `synthesize_plugin_json_from_apm_yml` emits only eight identity fields. - `owner.email` was dropped on a false premise; `yml_schema.py` accepts it. Restored. - Four plugins shipped changed compiled output under unchanged versions, against the policy this PR itself wrote. Bumped. - AGENTS.md's offline-push instruction did not work: `apm-pack-check-clean` also needs the network, and the same paragraph forbade adding anything else to `SKIP`. - A commit rationale described a defect that cannot occur (the `PROBES_CHECKED` guard); the guard's two *reachable* triggers had no test and now do. ### New gates - `check-apm-agents-valid` — `agent-audit`'s validator had never run against the four real agent files it governs, only synthetic fixtures. That is precisely why an amended ADR and a validator that rejected the field it blessed could disagree unnoticed. - `run-bats.sh` derives its expected `.bats` set from `git ls-files` instead of a hardcoded floor — the old floor of 8 against a real 10 let two files and eleven tests disappear green. Pre-push: **12 → 13** repo-defined hooks (15 total). AGENTS.md's counts, hook list and offline note updated to match. ### Verification Every behavioural fix is mutation-verified — test written, fix reverted, test observed **RED**, fix reapplied, **GREEN**. A test nobody watched fail was not counted. - `bash tests/run-tests.sh --strict` → 18 passed, 0 skipped, 0 failed; 166 bats tests - All 15 pre-push hooks pass - `sync-plugin-content --check --all` exits 0 under umask 022, 002 and 077 - `shellcheck -x --severity=warning` clean across all changed shell files - Compiled output byte-reproducible: `apm pack` → both syncs → empty `git status` ### Known-open, deliberately out of scope Three doc defects that pre-date this PR: `ADR-0011`'s stale `plugins/bin/skills/gitea/` reference, `LESSONS.md`'s citations of `scripts/gitleaks.toml` and `tests/test-plugin-validate.sh` (neither exists), and `architecture.md:45` calling the enforcement layer "Phase 2 (planned)" while 15 hooks run today. **There is still no CI in this repo.** Merging via the web UI runs nothing. Merge from a local push, or run `pre-commit run --hook-stage pre-push --all-files` first. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
Defame1297 approved these changes 2026-08-14 14:32:31 +00:00
Defame1297 merged commit c3ec5f2d3d into main 2026-08-14 14:32:36 +00:00
Defame1297 deleted branch feat/90-execute-apm-conversion 2026-08-14 14:32:36 +00:00
Sign in to join this conversation.