Compare commits
15
Commits
0f0ac5821f
...
a4a075b07e
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a4a075b07e | ||
|
|
b8dc400365 | ||
|
|
cf625229f7 | ||
|
|
a155af6827 | ||
|
|
c16ec2d45a | ||
|
|
f4bb1cf4e5 | ||
|
|
a700b3771c | ||
|
|
aa15fc850c | ||
|
|
874bf06b18 | ||
|
|
430f46b8e8 | ||
|
|
7ba3d9cf1d | ||
|
|
52bbd62286 | ||
|
|
af085ed057 | ||
|
|
3f1ee47f1e | ||
|
|
9e612fd183 |
No files matched your search
@@ -1,9 +1,10 @@
|
||||
{
|
||||
"name": "holocron",
|
||||
"description": "AI development skills for Claude Code and GitHub Copilot CLI — factory, design, implement, review, and cross-cutting workflows.",
|
||||
"version": "0.3.3",
|
||||
"version": "0.3.4",
|
||||
"owner": {
|
||||
"name": "Defame1297",
|
||||
"email": "[email protected]",
|
||||
"url": "https://git.dev.rkdr.net/Defame1297/"
|
||||
},
|
||||
"plugins": [
|
||||
@@ -17,21 +18,21 @@
|
||||
{
|
||||
"name": "bin",
|
||||
"description": "A place for things to be binned",
|
||||
"version": "1.1.1",
|
||||
"version": "1.1.2",
|
||||
"category": "Utilities",
|
||||
"source": "./plugins/bin"
|
||||
},
|
||||
{
|
||||
"name": "git",
|
||||
"description": "Skills for working with Git — conventional commits, branch management, pull requests, and feature flow.",
|
||||
"version": "1.3.2",
|
||||
"version": "1.3.3",
|
||||
"category": "Version Control",
|
||||
"source": "./plugins/git"
|
||||
},
|
||||
{
|
||||
"name": "gitea",
|
||||
"description": "Skills for managing Gitea repositories — issues, pull requests, milestones, releases, and wikis.",
|
||||
"version": "1.3.3",
|
||||
"version": "1.3.4",
|
||||
"category": "Version Control",
|
||||
"source": "./plugins/gitea"
|
||||
},
|
||||
@@ -45,6 +46,7 @@
|
||||
{
|
||||
"name": "mattpocock-skills",
|
||||
"description": "Skills for Real Engineers — planning, TDD, architecture, and debugging workflows from Matt Pocock's .claude directory.",
|
||||
"version": "1.2.3",
|
||||
"category": "Productivity",
|
||||
"source": {
|
||||
"source": "github",
|
||||
@@ -57,7 +59,7 @@
|
||||
{
|
||||
"name": "lint",
|
||||
"description": "Skills and agents for configuring and running linters.",
|
||||
"version": "1.1.5",
|
||||
"version": "1.1.6",
|
||||
"category": "Developer Tools",
|
||||
"source": "./plugins/lint"
|
||||
}
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
{
|
||||
"name": "holocron",
|
||||
"description": "AI development skills for Claude Code and GitHub Copilot CLI — factory, design, implement, review, and cross-cutting workflows.",
|
||||
"version": "0.3.3",
|
||||
"version": "0.3.4",
|
||||
"owner": {
|
||||
"name": "Defame1297",
|
||||
"email": "[email protected]",
|
||||
"url": "https://git.dev.rkdr.net/Defame1297/"
|
||||
},
|
||||
"plugins": [
|
||||
@@ -17,21 +18,21 @@
|
||||
{
|
||||
"name": "bin",
|
||||
"description": "A place for things to be binned",
|
||||
"version": "1.1.1",
|
||||
"version": "1.1.2",
|
||||
"category": "Utilities",
|
||||
"source": "./plugins/bin"
|
||||
},
|
||||
{
|
||||
"name": "git",
|
||||
"description": "Skills for working with Git — conventional commits, branch management, pull requests, and feature flow.",
|
||||
"version": "1.3.2",
|
||||
"version": "1.3.3",
|
||||
"category": "Version Control",
|
||||
"source": "./plugins/git"
|
||||
},
|
||||
{
|
||||
"name": "gitea",
|
||||
"description": "Skills for managing Gitea repositories — issues, pull requests, milestones, releases, and wikis.",
|
||||
"version": "1.3.3",
|
||||
"version": "1.3.4",
|
||||
"category": "Version Control",
|
||||
"source": "./plugins/gitea"
|
||||
},
|
||||
@@ -45,6 +46,7 @@
|
||||
{
|
||||
"name": "mattpocock-skills",
|
||||
"description": "Skills for Real Engineers — planning, TDD, architecture, and debugging workflows from Matt Pocock's .claude directory.",
|
||||
"version": "1.2.3",
|
||||
"category": "Productivity",
|
||||
"source": {
|
||||
"source": "github",
|
||||
@@ -57,7 +59,7 @@
|
||||
{
|
||||
"name": "lint",
|
||||
"description": "Skills and agents for configuring and running linters.",
|
||||
"version": "1.1.5",
|
||||
"version": "1.1.6",
|
||||
"category": "Developer Tools",
|
||||
"source": "./plugins/lint"
|
||||
}
|
||||
|
||||
+78
-5
@@ -28,7 +28,16 @@ repos:
|
||||
- id: pretty-format-json
|
||||
stages: ['pre-commit']
|
||||
args: [--autofix]
|
||||
exclude: '(^|/)(\.claude-plugin/plugin\.json|\.github/plugin/plugin\.json|\.claude-plugin/marketplace\.json|\.github/plugin/marketplace\.json)$|^\.agents/plugins/marketplace\.json$'
|
||||
# Every generated manifest lives at a KNOWN path, so every alternative is
|
||||
# root-anchored and spells that path out. This was five `(^|/)`
|
||||
# any-depth alternatives plus one `^` root-only one -- a mixture with no
|
||||
# rationale, under which a fixture or vendored tree containing
|
||||
# `.../.claude-plugin/plugin.json` would have been silently excluded from
|
||||
# formatting while an equivalent `.../.agents/plugins/marketplace.json`
|
||||
# would not. All fifteen real files (3 root marketplace manifests, 2 per
|
||||
# plugin x 6 plugins) match; anything else is hand-authored and gets
|
||||
# formatted.
|
||||
exclude: '^(\.claude-plugin/marketplace\.json|\.agents/plugins/marketplace\.json|\.github/plugin/marketplace\.json|plugins/[^/]+/\.claude-plugin/plugin\.json|plugins/[^/]+/\.github/plugin/plugin\.json)$'
|
||||
- id: check-yaml
|
||||
stages: ['pre-commit']
|
||||
- id: trailing-whitespace
|
||||
@@ -46,8 +55,8 @@ repos:
|
||||
hooks:
|
||||
- id: run-tests
|
||||
name: Run test suite
|
||||
description: Run all test-*.sh files and bats suite
|
||||
entry: bash tests/run-tests.sh
|
||||
description: Run all test-*.sh files and bats suite. --strict because a suite that exits 77 (SKIPPED) at pre-push means a documented dependency is missing on this machine, and pre-commit prints nothing for a passing hook -- without it the gate went green having verified 15 of 17 suites on a vale-less PATH, with the skip list swallowed. Ad-hoc `bash tests/run-tests.sh` still skips gracefully.
|
||||
entry: bash tests/run-tests.sh --strict
|
||||
language: system
|
||||
stages: [pre-push]
|
||||
pass_filenames: false
|
||||
@@ -91,12 +100,66 @@ repos:
|
||||
|
||||
- id: apm-audit-ci
|
||||
name: apm audit --ci
|
||||
description: apm's own producer-side lockfile/policy/hidden-content integrity gate, per apm's documented recommended CI block (see docs/research/docs/microsoft-apm/testing-and-validation.md)
|
||||
entry: apm audit --ci
|
||||
description: Run apm's producer-side CI gate over the root manifest AND each of the six plugin packages. Verifies exactly two things per manifest -- apm.yml parses as a valid APM manifest (manifest-parse), and, if it declares dependencies, apm.lock.yaml exists and is consistent (lockfile-exists). It does NOT enforce an org policy and does NOT scan for hidden Unicode; see the comment below for why. Reference:plugins/kyberforge/.apm/skills/apm-workflow/references/audit.md
|
||||
entry: bash -c 'for d in . plugins/*/; do (cd "$d" && apm audit --ci) || { echo "apm audit --ci failed in $d" >&2; exit 1; }; done'
|
||||
language: system
|
||||
stages: [pre-push]
|
||||
pass_filenames: false
|
||||
always_run: true
|
||||
# The description above deliberately claims less than this hook's old one
|
||||
# did ("lockfile/policy/hidden-content integrity"), because two of those
|
||||
# three were never happening:
|
||||
#
|
||||
# * POLICY. `apm audit --ci` discovers an org policy from the git remote,
|
||||
# and apm's discovery only understands github.com and Azure DevOps.
|
||||
# This repo's remote is a self-hosted Gitea, so discovery resolves
|
||||
# nothing and the run prints `No org policy found at unknown;
|
||||
# enforcement skipped`. apm's own message suggests
|
||||
# `policy.fetch_failure_default=block` in apm.yml "to fail closed" --
|
||||
# that was tried on a scratch copy and REJECTED: it does not make the
|
||||
# check meaningful, it makes it permanently red. `apm audit --ci` then
|
||||
# exits 1 with `No org policy found at unknown
|
||||
# (policy.fetch_failure_default=block)` on every push, because there is
|
||||
# no org policy to find and no supported way for this remote to serve
|
||||
# one. A gate that can never go green is not a gate. Revisit if this
|
||||
# repo ever gains a policy source apm can actually reach.
|
||||
# * HIDDEN CONTENT. The hidden-Unicode scan is plain `apm audit`, not
|
||||
# `apm audit --ci` (the two are different modes, and --ci refuses to
|
||||
# combine with --file/--strip/--dry-run/PACKAGE). Plain `apm audit`
|
||||
# here reports `No apm.lock.yaml found -- nothing to scan` and exits 0,
|
||||
# so adding it would buy a second vacuous check, not coverage.
|
||||
#
|
||||
# What IS left is worth keeping, and is now run against seven manifests
|
||||
# instead of one. lockfile-exists is conditional -- it is vacuous while
|
||||
# every apm.yml declares `dependencies: {apm: [], mcp: []}`, and it arms
|
||||
# itself the moment one does not (verified: adding a git dependency to
|
||||
# plugins/lint/apm.yml fails with `apm.yml declares dependencies but
|
||||
# apm.lock.yaml is absent`). manifest-parse is unconditional and fires on
|
||||
# any malformed manifest (verified: a dependency entry missing its
|
||||
# git/path/registry field fails with `Cannot parse apm.yml`). Running the
|
||||
# six plugin packages is what makes either reachable for them at all --
|
||||
# the root-only invocation audits the marketplace manifest and nothing
|
||||
# else. Costs ~0.5s per package, needs no network (checked under
|
||||
# `unshare -rn`), so this does NOT join apm-marketplace-check and
|
||||
# apm-pack-check-clean on the offline SKIP= list.
|
||||
|
||||
- id: check-apm-agents-valid
|
||||
name: Validate real APM agent files
|
||||
description: Run agent-audit's validate.sh over every plugins/*/.apm/agents/*.agent.md file in this repo -- the artifacts it governs, not fixtures
|
||||
entry: bash scripts/check-apm-agents-valid.sh
|
||||
language: system
|
||||
stages: [pre-push]
|
||||
pass_filenames: false
|
||||
always_run: true
|
||||
# validate.sh was previously exercised only by check-scope-walkup-sync,
|
||||
# and only against synthetic mktemp fixtures -- it had never run against
|
||||
# the four agent files it governs. That is how ADR-0016 could be amended
|
||||
# to bless a `disallowedTools` frontmatter field while validate.sh's
|
||||
# allowlist still rejected it: the spec and its enforcer disagreed and
|
||||
# every gate stayed green. The expected file set is derived from
|
||||
# `git ls-files` (the pattern tests/run-bats.sh established) rather than
|
||||
# a hardcoded count, and discovering zero files is an error, not a pass.
|
||||
# Needs no network.
|
||||
|
||||
- id: apm-pack-check-clean
|
||||
name: apm pack --check-clean
|
||||
@@ -115,6 +178,16 @@ repos:
|
||||
stages: [pre-push]
|
||||
pass_filenames: false
|
||||
always_run: true
|
||||
# verbose so the DOWNGRADED run is audible. This hook can pass while
|
||||
# having verified strictly less than its name claims:
|
||||
# CHECK_VALE_STYLE_SYNC_ALLOW_MISSING_VALE=1 skips all six glob probes
|
||||
# and says so on a `passed (text-level only, vale unavailable)` line.
|
||||
# pre-commit prints nothing at all for a passing hook, so without this
|
||||
# the opt-out reinstated exactly the silent vacuous pass the script was
|
||||
# written to kill, one level up -- the run showed a bare `Passed` and
|
||||
# AGENTS.md's instruction to read that summary line was impossible to
|
||||
# follow in the one situation the opt-out exists for. The script's clean
|
||||
# output is a single line, so this costs one line per push.
|
||||
|
||||
- id: check-scope-walkup-sync
|
||||
name: Check scope walk-up implementations agree
|
||||
|
||||
@@ -9,12 +9,16 @@ This repo is the global AI development configuration repository — the authorit
|
||||
|
||||
## Edit `.apm/`, never the flat mirror
|
||||
|
||||
Inside a plugin, `plugins/<name>/.apm/` is the **only** hand-edited content source. Everything else in a plugin root is generated:
|
||||
Inside a plugin, `plugins/<name>/.apm/` is the **only** hand-edited source for **plugin content** — the skills, agents, commands, instructions, extensions and hooks a host discovers. Everything in a plugin root that mirrors an `.apm/` primitive, plus both `plugin.json` manifests, is generated:
|
||||
|
||||
- `scripts/sync-plugin-content.sh` generates the flat `plugins/<name>/{skills,agents,commands,instructions,extensions}/` directories and the merged `plugins/<name>/hooks/hooks.json` (ADR-0017)
|
||||
- `apm pack` generates both per-plugin manifests — `plugins/<name>/.claude-plugin/plugin.json` and `plugins/<name>/.github/plugin/plugin.json` — and **two of the three** root marketplace manifests: `.claude-plugin/marketplace.json` (apm's `claude` output profile) and `.agents/plugins/marketplace.json` (its `codex` profile, a differently-shaped file) (ADR-0015)
|
||||
- `scripts/sync-marketplace-mirror.sh` generates the third, `.github/plugin/marketplace.json` — Copilot CLI's legacy manifest path. **No apm output profile targets it**: apm ships exactly two marketplace output profiles, `claude` and `codex` (documented in `plugins/kyberforge/.apm/skills/apm-workflow/references/marketplace.md`). The mirror is a byte-identical copy of `.claude-plugin/marketplace.json`, gated by the `check-marketplace-mirror-sync` pre-push hook. Do not expect `apm pack` to refresh it — that assumption is exactly the drift this pair exists to prevent
|
||||
|
||||
**A plugin root is not wholly generated.** Material that is not an `.apm/` primitive is hand-authored there and no compiler touches it: `README.md`, `docs/`, `bin/`, `sources.md`, `.mcp.json`, plus per-plugin extras like `plugins/git/config.example.json`, `plugins/gitea/references/` and `plugins/bin/evals/`. Edit those in place — they have no `.apm/` source, and looking for one wastes a search. The rule is per-path, not per-directory: `plugins/<name>/skills/` is generated, `plugins/<name>/docs/` is not. `docs/spec/architecture.md` carries the same carve-out.
|
||||
|
||||
One qualification: "hand-authored, untouched" holds only at the plugin *root*. A file placed **inside** a mirrored directory is destroyed — `sync_dir` runs `rm -rf "$dst"` before every copy, so a `README.md` under `plugins/<name>/hooks/` or `plugins/<name>/skills/` is deleted on the next sync whether or not `.apm/` has a counterpart. Put root-level plugin documentation in `docs/`, never in a mirrored directory.
|
||||
|
||||
Nothing labels a generated file as generated — `plugins/kyberforge/skills/forge/SKILL.md` is byte-identical to its `.apm/` original, with no marker in either. Check the path before you edit. An edit to the mirror is discarded by the next sync and is reported as drift by the `check-plugin-content-sync` pre-push hook, which is the earliest anyone finds out. Details in `docs/spec/architecture.md`.
|
||||
|
||||
## Prefer plugin skills over raw shell
|
||||
@@ -31,13 +35,18 @@ Fall back to raw shell only when no skill covers it.
|
||||
|
||||
## Setup and testing
|
||||
|
||||
- Install git hooks via `git:pc-run`, wiring all three stages — this repo's `.pre-commit-config.yaml` has no `default_install_hook_types`, so a plain install silently skips `commit-msg` (Conventional Commits) and `pre-push` (the 12-hook gate described below).
|
||||
- Install the `apm` CLI — four pre-push hooks shell out to it: `apm-marketplace-check`, `apm-audit-ci`, `apm-pack-check-clean`, and `check-plugin-content-sync` (via `scripts/sync-plugin-content.sh`, which wraps `apm pack`). The first three are bare `apm …` hook entries, so without it the push dies with an unhelpful "command not found". Use `kyberforge:apm-install`, or `curl -sSL https://aka.ms/apm-unix | sh`; verify with `apm --version`.
|
||||
- Install git hooks via `git:pc-run`, wiring all three stages — this repo's `.pre-commit-config.yaml` has no `default_install_hook_types`, so a plain install silently skips `commit-msg` (Conventional Commits) and `pre-push` (the 13-hook gate described below).
|
||||
- Install the `apm` CLI — four pre-push hooks shell out to it: `apm-marketplace-check`, `apm-audit-ci`, `apm-pack-check-clean`, and `check-plugin-content-sync` (via `scripts/sync-plugin-content.sh`, which wraps `apm pack`). `apm-marketplace-check` and `apm-pack-check-clean` are bare `apm …` hook entries and `apm-audit-ci` is a `bash -c` loop calling `apm` once per package, so without it the push dies with an unhelpful "command not found". Use `kyberforge:apm-install`, or `curl -sSL https://aka.ms/apm-unix | sh`; verify with `apm --version`.
|
||||
- Install `jq` — required by `scripts/check-manifests.sh` and `scripts/sync-plugin-content.sh`, both pre-push. These at least fail loudly (`Error: jq is required but not installed`).
|
||||
- Install the `vale` binary — required by the `vale-audit-prefilter-skill`/`-agent` pre-commit hooks. Their `files:` patterns are `.apm/`-scoped: `^plugins/[^/]+/\.apm/skills/[^/]+/SKILL\.md$` and `^plugins/[^/]+/\.apm/agents/[^/]+\.agent\.md$`. Only the authoring source triggers them — a `SKILL.md` in the generated mirror matches neither pattern, so prose findings surface only when you edit the file you are supposed to be editing. Without the binary the hooks fail with a bare "command not found" and no install pointer. `brew install vale` (macOS), `snap install vale` (Linux), `choco install vale` (Windows), or see https://vale.sh/docs/vale-cli/installation/. No `vale sync` needed — the `Kyberforge` styles are committed under `plugins/kyberforge/.apm/skills/{skill-audit,agent-audit}/assets/vale/styles/`, not downloaded packages (see ADR-0014).
|
||||
- `vale` is also a **pre-push** dependency, not only pre-commit. `check-vale-style-sync` runs six glob-coverage probes by invoking `vale --config` — they are the only assertions in it that catch a `.vale.ini` glob typo, the failure mode where every text-level check stays clean while vale lints zero files. Missing `vale` is therefore a hard failure there. The opt-out is `CHECK_VALE_STYLE_SYNC_ALLOW_MISSING_VALE=1`, and it is **not** `SKIP=`: the hook still runs and still asserts everything verifiable from file text, but the six probes do not, and its summary says so explicitly — `Vale style sync check passed (text-level only, vale unavailable): … 0 glob probe(s) verified`. Use it only on a machine that genuinely cannot install `vale`, and read that summary line as "the glob axis was not checked", not as a pass.
|
||||
- Run `bash tests/run-tests.sh` before considering any change done — it runs every `test-*.sh` script in the repo plus the bats suite (`--bats-only` for just bats). First run auto-initializes the bats submodules; no manual `git submodule update` needed.
|
||||
- Pushing runs 12 repo-defined pre-push hooks, not just the test suite — `run-tests` and `check-manifests`, plus generated-content drift gates (`check-plugin-content-sync`, `check-marketplace-mirror-sync`, `check-vale-style-sync`, `check-scope-walkup-sync`), apm's own gates (`apm-marketplace-check`, `apm-audit-ci`, `apm-pack-check-clean`), host validators (`validate-plugins`, `validate-marketplace`, both needing the `claude` CLI), and `check-release-needed`. Run `pre-commit run --hook-stage pre-push --all-files` locally — one command, the whole gate. That command reports **14**, not 12: pre-commit's own `meta` hooks, `check-hooks-apply` and `check-useless-excludes`, declare no `stages:` and so run at every stage including this one.
|
||||
- `apm-marketplace-check` needs the network. It resolves every `marketplace.packages[]` entry including the remote `mattpocock-skills` ref, and it is `always_run`, so an unreachable network hard-fails the push. `--offline` is not an escape hatch — it still exits 1 on that entry (`No cached refs (offline)`). To push without a network, skip that one hook using pre-commit's own mechanism: `SKIP=apm-marketplace-check git push`. Skip that hook alone — it is the only one whose failure mode is "no network". Every other pre-push hook is a real local check, and adding it to `SKIP` disarms it silently.
|
||||
- A suite that exits 77 because a dependency is missing is reported as SKIPPED, and does **not** fail an ad-hoc run. The pre-push hook invokes the same script as `--strict` (`RUN_TESTS_STRICT=1` is equivalent), where a skip **does** fail the push: at pre-push a skip means one of the dependencies above is absent on this machine, so the gate would otherwise report success having run fewer suites than it appears to. Without vale, for instance, three suites skip (`test-check-vale-style-sync.sh`, `test-vale-hooks-consumer.sh`, `test-vale-wrap.sh`) and the strict failure names each one and what to install.
|
||||
- `tests/run-bats.sh` derives the set of `.bats` files it expects from `git ls-files`, so a `.bats` file deleted from the worktree but still tracked in the index fails the run rather than silently shrinking the suite. Remove one with `git rm` (or stage the deletion) when the removal is intentional; an untracked new `.bats` file is picked up and needs no ceremony.
|
||||
- Pushing runs 13 repo-defined pre-push hooks, not just the test suite — `run-tests` and `check-manifests`, plus generated-content drift gates (`check-plugin-content-sync`, `check-marketplace-mirror-sync`, `check-vale-style-sync`, `check-scope-walkup-sync`), artifact validators (`check-apm-agents-valid`, which runs agent-audit's `validate.sh` over every real `plugins/*/.apm/agents/*.agent.md`), apm's own gates (`apm-marketplace-check`, `apm-audit-ci`, `apm-pack-check-clean`), host validators (`validate-plugins`, `validate-marketplace`, both needing the `claude` CLI), and `check-release-needed`. Run `pre-commit run --hook-stage pre-push --all-files` locally — one command, the whole gate. That command reports **15**, not 13: pre-commit's own `meta` hooks, `check-hooks-apply` and `check-useless-excludes`, declare no `stages:` and so run at every stage including this one.
|
||||
- `apm-audit-ci` runs `apm audit --ci` once per manifest — the root one and each of the six plugin packages — because the root-only invocation audits the marketplace manifest and **nothing else**, and `apm-pack-check-clean` does not parse plugin `dependencies:` blocks either (verified: a malformed one passes `apm pack --check-versions --check-clean --dry-run` and fails `apm audit --ci` in that package's directory). It verifies two things and claims no more: each `apm.yml` parses as a valid APM manifest, and any package declaring dependencies has a consistent `apm.lock.yaml`. It does **not** enforce an org policy — apm discovers one from the git remote and only understands github.com and Azure DevOps, so against this repo's self-hosted Gitea remote it prints `No org policy found at unknown; enforcement skipped`. Do **not** "fix" that with `policy.fetch_failure_default: block` in `apm.yml`: it was tested and rejected, because with no reachable policy source it makes the hook exit 1 on every push forever.
|
||||
- `check-apm-agents-valid` derives its expected agent-file set from `git ls-files` (same pattern as `tests/run-bats.sh`), so an agent file deleted from the worktree but still tracked fails the run, and discovering zero agent files is an error rather than a pass. An untracked new agent file is still validated — the derivation is one-directional on purpose, so uncommitted work is not blocked but also cannot bypass the gate.
|
||||
- **Two** pre-push hooks need the network, for one shared reason: root `apm.yml`'s `marketplace.packages[]` contains exactly one remote entry (`mattpocock-skills`, `source: mattpocock/skills`), and resolving it needs a `git ls-remote`. `apm-marketplace-check` resolves every entry and is `always_run`, so it fails with `No cached refs (offline)`. `apm-pack-check-clean` (`apm pack --check-versions --check-clean --dry-run`) re-resolves the same entry and fails with `Error: Git network timeout during ls-remote`. Pinning the entry to an exact version does **not** remove the call — an exact pin still ls-remotes. `--offline` rescues neither. To push without a network, skip both using pre-commit's own mechanism: `SKIP=apm-marketplace-check,apm-pack-check-clean git push`. Skip those two alone — verified under `unshare -rn`, the other eleven pre-push hooks pass offline because they are real local checks, and adding one of them to `SKIP` disarms it silently. `apm-audit-ci` calls `apm` too but stays local: its org-policy discovery resolves nothing on this remote before any network call, so it does not join the pair above.
|
||||
- Author commits with `git:git-commits` — it validates Conventional Commits (enforced at `commit-msg`) for you.
|
||||
|
||||
## Key documents
|
||||
|
||||
+3
-1
@@ -2,7 +2,7 @@
|
||||
|
||||
Patterns observed during development of this repo. Three or more entries on the same pattern → promote to CONTEXT.md (or the relevant instruction file) as a standing rule.
|
||||
|
||||
**Graduation rule:** When three or more entries cover the same pattern, the human reviews and promotes it to the appropriate standing location: `CONTEXT.md` for domain-level principles, `core/instructions/coding.md` for coding conventions, `core/instructions/git.md` for git conventions, or `core/instructions/testing.md` for testing conventions. The graduated entries are marked `[graduated → target file]` rather than deleted (audit trail).
|
||||
**Graduation rule:** When three or more entries cover the same pattern, the human reviews and promotes it to the appropriate standing location: `CONTEXT.md` for domain-level principles, `core/instructions/coding.md` for coding conventions, `core/instructions/testing.md` for testing conventions, or `core/instructions/subagent-orchestration.md` for delegation conventions. Those four are the whole set — `core/instructions/` holds `coding.md`, `governance.md`, `subagent-orchestration.md` and `testing.md`, and nothing else. Git conventions have no standing file of their own: promote them to `core/instructions/coding.md`, or create a new instruction file deliberately rather than assuming one exists. The graduated entries are marked `[graduated → target file]` rather than deleted (audit trail).
|
||||
|
||||
**Who writes here:** The session-handoff skill (Chunk 3) prompts LESSONS.md extraction before closing a session. The human may also write directly.
|
||||
|
||||
@@ -26,6 +26,8 @@ Issue files frequently referenced "the workflow defined in `docs/notes/skill-imp
|
||||
|
||||
The repo CLAUDE.md instructs agents to read CONTEXT.md at session start, but agents skip this in practice — defaulting to reading only what's directly relevant to the immediate prompt (e.g. the skills folder). The governance.md works because `@import` is technically enforced by Claude Code. Fix: (1) add `@CONTEXT.md` to repo CLAUDE.md using `@import` to make it always-loaded; (2) add a "Key decisions" section to CONTEXT.md with one-line resolved-ADR summaries so locked choices are always in context.
|
||||
|
||||
**Status (2026-08-14): neither part landed.** Root `CLAUDE.md` imports `@AGENTS.md` only — no `@CONTEXT.md` — and `CONTEXT.md` has no "Key decisions" section. The behavioral hope this entry diagnosed is still the only mechanism in place: `AGENTS.md` carries the line "Read CONTEXT.md at the start of every session in this repo," which is loaded but is itself an instruction, not an import. The proposal above is open work, not a record of a completed change.
|
||||
|
||||
## 2026-05-17 — Instruction rules lose to RLHF defaults without specificity
|
||||
|
||||
Behavioral tests (2026-05-17) showed three communication/behavior rules failing: exploratory question format (gave verbose multi-bullet answer instead of 2-3 sentences), file edit intent (asked for clarification instead of stating intent and proceeding), and push confirmation (went straight to tool call instead of asking first). All three rules are present in `providers/claude-code/CLAUDE.md` as one-liner statements. The RLHF-trained defaults (thorough answers, risk-averse clarification seeking, fast execution) consistently outcompete thin rules. Fix: rewrite failing rules with specificity, a counter-example, and a boundary statement — not just a single-line imperative.
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
name: holocron
|
||||
version: 0.3.3
|
||||
version: 0.3.4
|
||||
description: AI development skills for Claude Code and GitHub Copilot CLI — factory, design, implement, review, and cross-cutting workflows.
|
||||
license: MIT
|
||||
marketplace:
|
||||
@@ -8,9 +8,10 @@ marketplace:
|
||||
# top-level apm.yml description:/version: above are NOT inherited into the
|
||||
# compiled output despite being used elsewhere (e.g. by `apm audit`).
|
||||
description: AI development skills for Claude Code and GitHub Copilot CLI — factory, design, implement, review, and cross-cutting workflows.
|
||||
version: 0.3.3
|
||||
version: 0.3.4
|
||||
owner:
|
||||
name: Defame1297
|
||||
email: [email protected]
|
||||
url: https://git.dev.rkdr.net/Defame1297/
|
||||
|
||||
# Default tag pattern used to resolve version ranges for each package.
|
||||
@@ -40,19 +41,19 @@ marketplace:
|
||||
- name: bin
|
||||
description: A place for things to be binned
|
||||
source: ./plugins/bin
|
||||
version: 1.1.1
|
||||
version: 1.1.2
|
||||
category: Utilities
|
||||
|
||||
- name: git
|
||||
description: Skills for working with Git — conventional commits, branch management, pull requests, and feature flow.
|
||||
source: ./plugins/git
|
||||
version: 1.3.2
|
||||
version: 1.3.3
|
||||
category: Version Control
|
||||
|
||||
- name: gitea
|
||||
description: Skills for managing Gitea repositories — issues, pull requests, milestones, releases, and wikis.
|
||||
source: ./plugins/gitea
|
||||
version: 1.3.3
|
||||
version: 1.3.4
|
||||
category: Version Control
|
||||
|
||||
- name: core
|
||||
@@ -64,11 +65,11 @@ marketplace:
|
||||
- name: mattpocock-skills
|
||||
description: Skills for Real Engineers — planning, TDD, architecture, and debugging workflows from Matt Pocock's .claude directory.
|
||||
source: mattpocock/skills
|
||||
version: "^1.2.0"
|
||||
version: "1.2.3"
|
||||
category: Productivity
|
||||
|
||||
- name: lint
|
||||
description: Skills and agents for configuring and running linters.
|
||||
source: ./plugins/lint
|
||||
version: 1.1.5
|
||||
version: 1.1.6
|
||||
category: Developer Tools
|
||||
@@ -118,18 +118,21 @@ correction) sorted what they document into three buckets:
|
||||
match.
|
||||
- ADR-0016 (a narrower decision discovered while designing issue #89) turned out to gate how
|
||||
issue #90 had to re-author plugin-scope agents: `.apm/agents/*.agent.md` compiles verbatim to
|
||||
both Claude and Copilot, so those files carry only `name`/`description`/`model`/`source_keys` —
|
||||
existing dual-file `<name>.md`+`<name>.agent.md` pairs could not be raw-moved, only re-authored.
|
||||
both Claude and Copilot, so those files carry only the fields in the `apm-agent-allowlist` section
|
||||
of `plugins/kyberforge/.apm/skills/agent-audit/references/field-inventory.md` (as amended
|
||||
2026-08-14: `name`/`description`/`model`/`source_keys`/`disallowedTools`) — existing dual-file
|
||||
`<name>.md`+`<name>.agent.md` pairs could not be raw-moved, only re-authored.
|
||||
- Two follow-up issues tracked the remaining work: #89 (`skill-author`/`agent-author` routing
|
||||
adaptation — closed, merged in #93) and #90 (the actual repo conversion, which also deleted
|
||||
`plugin-author`/`marketplace-author` — tracked through to merge; treat #90's own state as the
|
||||
authority on whether it has landed, not this line).
|
||||
- **`displayName` is gone from all six compiled `plugin.json` files, and `owner.email` from the
|
||||
marketplace manifest — accepted, not overlooked.** `apm.yml` has no key that compiles to either,
|
||||
so the conversion dropped both: every `plugins/<name>/.claude-plugin/plugin.json` now carries
|
||||
- **`displayName` is gone from all six compiled `plugin.json` files — accepted, not overlooked.**
|
||||
`apm.yml` has no key that compiles to it: `synthesize_plugin_json_from_apm_yml`
|
||||
(`apm_cli/deps/plugin_parser.py`) emits only `name`, `version`, `description`, `author`,
|
||||
`license`, `homepage`, `repository` and `keywords`, and nothing in `plugin_manifest.py` adds
|
||||
`displayName` afterwards. So every `plugins/<name>/.claude-plugin/plugin.json` now carries
|
||||
`author`/`description`/`homepage`/`keywords`/`license`/`name`/`repository`/`version` (plus
|
||||
`mcpServers` for `bin`) and no `displayName`, and `.claude-plugin/marketplace.json`'s `owner`
|
||||
block is `{name, url}` only. Both fields are optional —
|
||||
`mcpServers` for `bin`) and no `displayName`. The field is optional —
|
||||
`plugins/kyberforge/docs/research/docs/claude-code-plugins/api-reference.md:14` lists
|
||||
`displayName` as `Required: No`, "Human-readable name shown in plugin manager" — which is why
|
||||
`claude plugin validate --strict` still passes on all six. The visible cost is that the plugin
|
||||
@@ -138,16 +141,31 @@ correction) sorted what they document into three buckets:
|
||||
`reinject_*` workaround of the kind ADR-0017's amendment reserves for fields apm strips on a
|
||||
factually wrong premise, and apm's premise here is simply that the key does not exist in its
|
||||
schema.
|
||||
- **`mattpocock-skills` is now version-pinned, and the pin is maintained by hand.** Pre-conversion
|
||||
the entry was `{"repo": "mattpocock/skills", "source": "github"}` — an unpinned reference that
|
||||
tracked the upstream default branch, so consumers got whatever was on it at install time. Root
|
||||
`apm.yml` now declares `version: "^1.2.0"` for it, which `apm pack` resolves and freezes into
|
||||
`.claude-plugin/marketplace.json` as `ref: v1.2.3` + an explicit `sha`. Consumers get a
|
||||
reproducible version instead of a moving target, which is the improvement; the cost is that
|
||||
nothing advances it. apm has no version-bump automation (established under "Versioning" in issue
|
||||
#90's plan), so picking up a new upstream release means a human editing the `version:` range in
|
||||
root `apm.yml` and re-running `apm pack`. Left un-bumped, the marketplace pins an ageing release
|
||||
indefinitely and silently.
|
||||
- **`owner.email` was dropped by mistake and has been restored (2026-08-14).** An earlier revision
|
||||
of this ADR listed `owner.email` alongside `displayName` as a field `apm.yml` "has no key that
|
||||
compiles to." That was wrong. `apm_cli/marketplace/yml_schema.py:186` defines
|
||||
`_AUTHOR_OBJECT_KEYS = frozenset({"name", "email", "url"})`, and an `email:` under root
|
||||
`apm.yml`'s `marketplace.owner` block was empirically confirmed to compile straight through into
|
||||
`.claude-plugin/marketplace.json`'s `owner`. The key is declared in root `apm.yml` again and the
|
||||
compiled `owner` block is `{name, email, url}`. Only `displayName` is a genuine schema gap; this
|
||||
one was a documentation error that removed working configuration.
|
||||
- **`mattpocock-skills` is pinned to an exact version, and the pin is advanced by hand.**
|
||||
Pre-conversion the entry was `{"repo": "mattpocock/skills", "source": "github"}` — an unpinned
|
||||
reference that tracked the upstream default branch, so consumers got whatever was on it at
|
||||
install time. The conversion first replaced that with `version: "^1.2.0"`, which was still not a
|
||||
pin: a caret range has nothing to freeze it, because there is no lockfile for
|
||||
`marketplace.packages[]`. `apm pack` re-resolved the range against upstream on **every** run, so
|
||||
an upstream `v1.2.4` would immediately invalidate the committed `ref`/`sha` and fail
|
||||
`apm-pack-check-clean` with exit 4 — blocking every push in the repo, triggered by a third party
|
||||
at an unrelated moment, with no local change to explain it. Root `apm.yml` therefore declares an
|
||||
exact `version: "1.2.3"`, which `apm pack` freezes into `.claude-plugin/marketplace.json` as
|
||||
`ref: v1.2.3` + an explicit `sha`. Two consequences, both intended: the committed ref/sha is
|
||||
genuinely reproducible and cannot move under the repo, and picking up a new upstream release is a
|
||||
deliberate act — a human edits the `version:` string in root `apm.yml` and re-runs `apm pack`.
|
||||
apm has no version-bump automation (established under "Versioning" in issue #90's plan), so an
|
||||
ageing pin is the accepted cost of a push gate that only fires on this repo's own changes.
|
||||
Note the pin does not make the entry offline-resolvable: an exact version still requires a
|
||||
`git ls-remote`, which is why two pre-push hooks need the network (see `AGENTS.md`).
|
||||
- **Caveat on "Status: executed" above:** issue #90's own execution comment flagged, before merge,
|
||||
that Claude Code's ability to actually load content out of `.apm/` was unverified — that caveat
|
||||
turned out to be a real defect, not a formality: the native installer has zero awareness of
|
||||
|
||||
@@ -22,13 +22,17 @@ Code and Copilot CLI targets. This is unlike:
|
||||
Because the agent primitive ships the same frontmatter unchanged to both harnesses, two
|
||||
concrete incompatibilities surface:
|
||||
|
||||
1. **`tools:`** — Claude Code expects a space-separated tool-name string; Copilot CLI expects a
|
||||
list drawn from its own alias vocabulary (`execute`/`read`/`edit`/`search`/`agent`/`web`). A
|
||||
value correct for one harness is wrong for the other.
|
||||
1. **`tools:`** — Claude Code expects tool names drawn from its own vocabulary, as a
|
||||
comma-separated string or a YAML list (`agent-definition.md:37`); Copilot CLI expects a list
|
||||
drawn from a different alias vocabulary (`execute`/`read`/`edit`/`search`/`agent`/`web`). The
|
||||
incompatibility is the vocabulary, not the punctuation: a value correct for one harness names
|
||||
tools the other does not have.
|
||||
2. **Claude-only knobs with no Copilot equivalent** — `isolation`, `maxTurns`, `effort`,
|
||||
`memory`, `permissionMode`. Writing any of these means Copilot's copy carries frontmatter
|
||||
keys it doesn't recognize at all. Whether Copilot's agent loader ignores unknown keys or
|
||||
errors on them is unconfirmed by research.
|
||||
errors on them is unconfirmed by research. *(Still unconfirmed as of the 2026-08-14 amendment
|
||||
below, which admits `disallowedTools` as an explicitly accepted risk rather than by resolving
|
||||
this question.)*
|
||||
|
||||
## Decision
|
||||
|
||||
@@ -36,6 +40,9 @@ At **plugin scope only** (destination package has an `apm.yml` at its root — a
|
||||
package compiled via `apm compile`), `.apm/agents/<name>.agent.md` carries only `name`,
|
||||
`description`, `model`, and the prose body. No `tools:` field, no Claude-only fields, at all.
|
||||
|
||||
*(Narrowed by the 2026-08-14 amendment below: `disallowedTools` is admitted as a fifth allowed
|
||||
field. `tools:` and every other Claude-only knob remain excluded on the reasoning given here.)*
|
||||
|
||||
Absent `tools:` means inherit-all-tools on both harnesses — the one value that is never wrong
|
||||
on either target, unlike a present, harness-specific value that is guaranteed wrong on at least
|
||||
one of them.
|
||||
@@ -70,11 +77,84 @@ harness. Tracking the breakage doesn't prevent it, and the chosen decision alrea
|
||||
equivalent visibility (a SUGGESTION finding) without ever shipping the wrong value in the first
|
||||
place.
|
||||
|
||||
## Amendment (2026-08-14): the write fence comes back as a denylist
|
||||
|
||||
The decision above generalised from `tools:` to "no tool restriction at all". That over-reached.
|
||||
The unportability argument is specific to the **allowlist**: Claude Code reads `tools:` as a
|
||||
delimited string of its own tool names, Copilot CLI reads it as a list drawn from its
|
||||
alias vocabulary (`execute`/`read`/`edit`/`search`/`agent`/`web`), so one value is wrong on one
|
||||
harness. That reasoning stands, and `tools:` stays out of every plugin-scope agent.
|
||||
|
||||
A **denylist** has no such conflict. The evidence for that splits three ways, and this amendment
|
||||
states which part is which rather than asserting the whole as settled.
|
||||
|
||||
**Confirmed — Claude Code honours it for plugin subagents.**
|
||||
`plugins/kyberforge/docs/research/docs/claude-code-plugins/agent-definition.md:39` documents
|
||||
`disallowedTools` as a "Denylist applied before `tools`… Takes precedence over `tools`", and — the
|
||||
part that matters here — it is **not** in that document's plugin-subagent ignore list. Line 99
|
||||
names exactly three fields plugin agents silently ignore: `hooks`, `mcpServers`, `permissionMode`.
|
||||
`disallowedTools` is absent from that list. Claude Code is also the harness where the fence is
|
||||
actually wanted, so the field earns its place on this evidence alone.
|
||||
|
||||
**Inferred — the field is very likely inert on Copilot CLI, but by analogy, not by documentation.**
|
||||
`plugins/kyberforge/docs/research/docs/github-copilot-plugins/troubleshooting.md:50` and `:53`
|
||||
record Copilot *silently ignoring* two agent frontmatter fields it does not process (`mcp-servers`
|
||||
and `metadata` outside the cloud runtime) rather than erroring on them. That is a documented
|
||||
tolerance for *known-but-unprocessed* keys, which is adjacent to, not identical to, tolerance for
|
||||
an *unknown* key. No stronger evidence exists: a sweep of the vendored Copilot corpus
|
||||
(`agent-definition.md`, `api-reference.md`, `troubleshooting.md`, `configuration.md`) documents
|
||||
unknown-key handling nowhere.
|
||||
|
||||
**Unverified — Copilot's loader behaviour on an unrecognised key.** Context item 2 above says this
|
||||
is unconfirmed by research and that remains true; nothing found since changes it. An earlier
|
||||
revision of this amendment claimed "an unrecognised frontmatter key is inert" as settled fact and
|
||||
attributed it to apm's verbatim-copy behaviour. That attribution was a non-sequitur — verbatim copy
|
||||
describes what *apm* does at compile time and says nothing about what *Copilot* does at load time —
|
||||
and the claim contradicted this ADR's own Context section.
|
||||
|
||||
**So this is an accepted risk, stated as one.** Blast radius if the inference is wrong and Copilot
|
||||
errors on the key: the three affected plugin-scope agents fail to load under Copilot CLI. It is
|
||||
loud, not silent; it is confined to three agents in three plugins; no other primitive and no Claude
|
||||
Code path is affected; and the remedy is a one-line frontmatter deletion. What the denylist shape
|
||||
*does* rule out categorically — independent of loader behaviour — is the failure mode that motivated
|
||||
dropping `tools:` in the first place: a denied name the other harness does not recognise denies
|
||||
nothing, so a mis-shaped value can never grant or misroute a capability. The risk is a load failure,
|
||||
never a silent over-grant. That asymmetry is why the same verbatim copy that makes `tools:`
|
||||
unshippable makes `disallowedTools` worth shipping.
|
||||
|
||||
So the read-only orchestrator agents regain their write fence: `gitea-orchestrate`,
|
||||
`apm-orchestrate` and `lint-runner` each carry `disallowedTools: Edit, Write, NotebookEdit` plus
|
||||
explicit prose in the body stating the agent does not edit files. `git-orchestrate` is deliberately
|
||||
excluded — it legitimately declared `edit` before the conversion and still needs to write.
|
||||
|
||||
**Residual — the fence is partial, and the prose is doing more of the work than the field is.**
|
||||
`disallowedTools: Edit, Write, NotebookEdit` denies exactly those three tools. It does not deny
|
||||
`Bash`, and at plugin scope these agents carry no `tools:` and therefore inherit it, so
|
||||
`bash -c 'echo … > f'` remains unfenced by frontmatter. Only the body prose covers that path. This
|
||||
is not a regression introduced here — the pre-conversion `tools:` allowlists also granted `Bash`,
|
||||
so the shell route was open then too — but the ADR should not credit the mechanism with more than
|
||||
it delivers. Closing it would need a `disallowedTools` entry for `Bash`, which these agents cannot
|
||||
take because they legitimately shell out.
|
||||
|
||||
Net position: the allowlist stays dropped for the reason originally given, and the denylist is
|
||||
admitted as the portable-by-construction half of what was lost. It restores a real, Claude-Code-
|
||||
confirmed write fence against the tool-call path, not a complete write sandbox. The consequence
|
||||
below is narrowed accordingly.
|
||||
|
||||
Enforcement follows the decision: `agent-audit`'s plugin-scope validator reads its allowlist as
|
||||
data from the `apm-agent-allowlist` section of
|
||||
`plugins/kyberforge/.apm/skills/agent-audit/references/field-inventory.md`, and that line now reads
|
||||
`name description model source_keys disallowedTools`. `disallowedTools` also stays in that file's
|
||||
`claude-code-only-fields` list, which is not a contradiction — that list governs whether a field
|
||||
may cross the CC/Copilot boundary in a real project/user-scope *pair*, a different question from
|
||||
whether a field is safe under verbatim copy in a single vendor-neutral file.
|
||||
|
||||
## Consequences
|
||||
|
||||
- Every plugin-scope APM agent loses per-agent tool restriction and any Claude-only capability
|
||||
- Every plugin-scope APM agent loses per-agent tool *allowlisting* and any Claude-only capability
|
||||
(isolation, maxTurns, effort, memory, permissionMode) until APM ships a real per-target
|
||||
integrator for the agent primitive. This is a known, accepted regression, not an oversight.
|
||||
Tool **denial** is not part of that loss — see the 2026-08-14 amendment above.
|
||||
- **ADR-0005 is partially superseded** — its plugin-scope clause ("directory containing
|
||||
`plugin.json` is plugin scope → both files land in `<root>/agents/`") no longer applies.
|
||||
Plugin scope is now "directory containing `apm.yml` → single vendor-neutral file lands in
|
||||
@@ -86,7 +166,9 @@ place.
|
||||
lists from `references/field-inventory.md` rather than hardcoding them, with a `source_keys`
|
||||
provenance chain — survives and is reused. Only the *content shape* changes for plugin scope:
|
||||
`field-inventory.md` shifts from two side-by-side CC-only/Copilot-only blocklists to one
|
||||
vendor-neutral allowlist (`name`/`description`/`model`/`source_keys` — the last for provenance
|
||||
tracking, validated separately by `validate-provenance.sh` against `sources.md`, not a
|
||||
provider-specific field) for plugin-scope agents, while
|
||||
continuing to serve its original two-blocklist role for project/user-scope validation.
|
||||
vendor-neutral allowlist for plugin-scope agents, while continuing to serve its original
|
||||
two-blocklist role for project/user-scope validation. That file's `apm-agent-allowlist` section
|
||||
is the authoritative list and is read as data by `validate.sh`; as amended on 2026-08-14 it holds
|
||||
`name`/`description`/`model`/`source_keys`/`disallowedTools` — `source_keys` for provenance
|
||||
tracking, validated separately by `validate-provenance.sh` against `sources.md` rather than being
|
||||
a provider-specific field, and `disallowedTools` per the amendment above.
|
||||
@@ -86,7 +86,11 @@ governance status as `.claude-plugin/plugin.json`/`marketplace.json`:
|
||||
`.pre-commit-config.yaml` as hook id `check-plugin-content-sync` by a parallel workstream on
|
||||
issue #90) — the same enforcement model `check-manifests.sh` already applies to the other
|
||||
compiled-output category. `--check` alone is not the gate: the script requires either `--all` or
|
||||
an explicit list of plugin directories, and run bare it prints usage and exits 1.
|
||||
an explicit list of plugin directories, and run bare it prints usage and exits 1. `--all` derives
|
||||
its work list from `marketplace.json`, a generated file, so it asserts its own coverage against
|
||||
that list: it fails if it verified fewer plugins than the marketplace declares, not merely if it
|
||||
verified none. A listed plugin whose `.apm/` has gone missing is skipped by the per-plugin sync
|
||||
and would otherwise let the gate report success over a shrinking work list.
|
||||
- Verified two ways before landing: `claude plugin validate --strict` passes on all 6 real
|
||||
(non-scratch) plugin directories, and a live behavioral test
|
||||
(`claude --plugin-dir plugins/kyberforge -p "list your skills and agents"`) against the real
|
||||
@@ -98,18 +102,29 @@ governance status as `.claude-plugin/plugin.json`/`marketplace.json`:
|
||||
|
||||
## Considered options
|
||||
|
||||
**Patch `plugin.json`'s `skills`/`agents`/`commands`/`hooks` fields to point directly at `.apm/`
|
||||
paths (rejected).** Claude Code's manifest schema documents these as legitimate override fields
|
||||
that accept custom paths —
|
||||
`plugins/kyberforge/docs/research/docs/claude-code-plugins/configuration.md` shows a real example
|
||||
(`"skills": "./custom/skills/"`, `"agents": ["./custom/agents/reviewer.md"]`), so the host side of
|
||||
this would work. Rejected because apm's compiler is not a passive pass-through: `build_plugin_manifest`
|
||||
unconditionally strips these keys from every manifest it generates, on the stated assumption that
|
||||
convention directories are always host-auto-discovered and therefore never need an explicit
|
||||
pointer. Honoring this option would mean post-processing apm's compiled output on every
|
||||
`apm pack` run to re-inject fields apm actively removes — fighting a stable, intentional apm code
|
||||
path indefinitely — rather than reusing `plugin_exporter.py`'s bundle-export mapping, which already
|
||||
does the right thing and only needed its output redirected to a path the installer reads.
|
||||
**Patch `plugin.json`'s content-pointer fields to point directly at `.apm/` paths (rejected).**
|
||||
Claude Code's manifest schema documents these as legitimate override fields that accept custom
|
||||
paths — `plugins/kyberforge/docs/research/docs/claude-code-plugins/configuration.md` shows a real
|
||||
example (`"skills": "./custom/skills/"`, `"agents": ["./custom/agents/reviewer.md"]`), so the host
|
||||
side of this would work. Rejected because apm never emits such a pointer and would have to be
|
||||
worked around on every run to make it do so.
|
||||
|
||||
Be precise about the mechanism, because an earlier revision of this ADR overstated it. apm 0.28.0's
|
||||
`build_plugin_manifest` (`apm_cli/core/plugin_manifest.py`) does carry a strip loop, but its field
|
||||
list is `("agents", "skills", "commands", "instructions")` — `hooks` is **not** in it, and
|
||||
`instructions` **is**, which this ADR previously did not mention. More to the point, that loop can
|
||||
never fire: the manifest it operates on comes from `synthesize_plugin_json_from_apm_yml`
|
||||
(`apm_cli/deps/plugin_parser.py`), which only ever emits `name`, `version`, `description`,
|
||||
`author`, `license`, `homepage`, `repository` and `keywords`. The pointer fields are absent from
|
||||
apm's output because `apm.yml` has no schema for them, not because apm actively removes them — the
|
||||
`pop` loop is defensive dead code against a manifest shape apm does not produce.
|
||||
|
||||
The rejection is unaffected by that correction, only its framing. Honoring this option would still
|
||||
mean post-processing apm's compiled output on every `apm pack` run to add fields apm's schema has
|
||||
no way to express, rather than reusing `plugin_exporter.py`'s bundle-export mapping, which already
|
||||
does the right thing and only needed its output redirected to a path the installer reads. What it
|
||||
is *not* is a fight against a load-bearing apm code path — the honest statement is that apm has no
|
||||
input for these fields, and inventing one downstream is a workaround this ADR did not need.
|
||||
|
||||
**Point `marketplace.json`'s `source:` at `apm pack`'s `build/<name>-<version>/` output directly
|
||||
(rejected).** Would reuse the bundle exporter's correct mapping without adding a new script.
|
||||
@@ -121,36 +136,50 @@ source and scans directories; it does not execute a package manager's build comm
|
||||
Copying the relevant subset back to the stable `plugins/<name>/` path — where `marketplace.json`
|
||||
already points — needed no change to the marketplace source model at all.
|
||||
|
||||
## Amendment (2026-08-13): `mcpServers` is narrowly reinjected into Copilot's `plugin.json`
|
||||
## Amendment (2026-08-13, revised 2026-08-14): Copilot's `plugin.json` gets an `mcpServers` *path*
|
||||
|
||||
PR #95's review (a follow-on to this same issue #90 workstream) found a second field apm's
|
||||
compiler strips for the Copilot ecosystem: `build_plugin_manifest` unconditionally removes
|
||||
`mcpServers` from every Copilot-ecosystem `plugin.json`, its docstring stating the field is "not
|
||||
part of the Copilot plugin manifest schema." That claim is contradicted by this repo's own
|
||||
researched documentation — `plugins/kyberforge/docs/research/docs/github-copilot-plugins/
|
||||
configuration.md:49` documents `mcpServers` as a valid, optional `plugin.json` field for Copilot.
|
||||
compiler drops for the Copilot ecosystem: `build_plugin_manifest` runs
|
||||
`manifest.pop("mcpServers", None)` on every Copilot-ecosystem `plugin.json`, its docstring stating
|
||||
the field is "not part of the Copilot plugin manifest schema." That claim is contradicted by this
|
||||
repo's own researched documentation —
|
||||
`plugins/kyberforge/docs/research/docs/github-copilot-plugins/configuration.md:49` documents
|
||||
`mcpServers` as a valid, optional `plugin.json` field, typed **"string or object — MCP server
|
||||
config path or inline definitions."**
|
||||
|
||||
This is not the same situation "Considered options" above rejected. That rejection concerned
|
||||
fields apm strips *correctly*, on a stable and accurate premise: convention directories
|
||||
(`skills/`, `agents/`, `commands/`) are host-auto-discovered, so an explicit pointer is redundant
|
||||
by design. Here, apm's own stated justification for stripping `mcpServers` is factually wrong
|
||||
against documented Copilot behavior — there is no host-auto-discovery mechanism that makes an
|
||||
explicit `mcpServers` declaration redundant, the way there is for skills/agents/commands. Applying
|
||||
the same "don't fight a stable, intentional apm code path" reasoning here would mean shipping a
|
||||
plugin manifest known to be missing a field Copilot actually reads.
|
||||
This is not the same situation "Considered options" above rejected. There, apm emits no pointer
|
||||
because its schema has no input for one and the host auto-discovers the directories anyway, so
|
||||
nothing is missing. Here a field Copilot actually reads is actively removed on a premise that is
|
||||
wrong against documented Copilot behavior, and there is no auto-discovery mechanism that makes it
|
||||
redundant. Shipping the manifest as apm produces it would ship a manifest known to be incomplete.
|
||||
|
||||
Given that, `scripts/sync-plugin-content.sh`'s `reinject_mcp_servers()`, called from `sync_one()`,
|
||||
narrowly re-injects `mcpServers` into `.github/plugin/plugin.json` after `apm pack` runs, sourced
|
||||
from the plugin's own `.mcp.json`, and only when it declares at least one server — matching apm's
|
||||
own Claude-ecosystem builder, which omits the field entirely rather than emitting
|
||||
`mcpServers: {}`. **Both modes re-inject**, not just real syncs: real mode writes into the plugin
|
||||
root directly, `--check` into its throwaway copy first, so the manifest diff compares against the
|
||||
same content a real sync would actually produce (see the script's own header). A check-mode
|
||||
re-injection is what keeps `--check` from reporting permanent phantom drift on every plugin that
|
||||
ships an `.mcp.json`. This is scoped to one field found to be incorrectly stripped, not a
|
||||
reversal of the broader position above: the rejection of patching
|
||||
`skills`/`agents`/`commands`/`hooks` pointers still holds, since apm's premise for stripping those
|
||||
remains accurate.
|
||||
`scripts/sync-plugin-content.sh`'s `reinject_mcp_servers()`, called from `sync_one()`, therefore
|
||||
sets `mcpServers` on `.github/plugin/plugin.json` after `apm pack` runs — to the **string
|
||||
`".mcp.json"`**, the path form of the documented type, not the resolved server objects. Only when
|
||||
the plugin's `.mcp.json` declares at least one server, matching apm's own Claude-ecosystem builder,
|
||||
which omits the field entirely rather than emitting `mcpServers: {}`.
|
||||
|
||||
**The payload is a path because an inlined object is a credential-leak path.** The original
|
||||
implementation copied `.mcp.json`'s resolved `mcpServers` object into the manifest with `jq`. That
|
||||
route bypasses apm's own `_sanitize_mcp_servers()` (`apm_cli/core/plugin_manifest.py`), which
|
||||
strips credential keys and redacts secret values out of `.mcp.json` precisely because — in its own
|
||||
words — "copying them verbatim into a committed `plugin.json` would exfiltrate them into the
|
||||
distributed artefact." Today's `.mcp.json` files here carry no `env` block, so nothing leaked; the
|
||||
first one that did would have written a live token into a tracked, published manifest, with the
|
||||
sanitizer sitting one code path away and never invoked. A path reference cannot carry a secret at
|
||||
all: the manifest names a file, and resolution happens in the host at load time. This also matches
|
||||
apm's documented posture for MCP secrets — `microsoft-apm/configuration.md:96-98` requires `${VAR}`
|
||||
indirection so secrets are "never committed to the manifest."
|
||||
|
||||
**Both modes re-inject**, not just real syncs: real mode writes into the plugin root directly,
|
||||
`--check` into its throwaway copy first, so the manifest diff compares against the same content a
|
||||
real sync would actually produce (see the script's own header). A check-mode re-injection is what
|
||||
keeps `--check` from reporting permanent phantom drift on every plugin that ships an `.mcp.json`.
|
||||
|
||||
This remains scoped to one field found to be incorrectly dropped. It does not reopen the
|
||||
content-pointer option rejected above: those fields stay absent because apm has no schema input for
|
||||
them and the host needs no pointer, which is a different situation from a documented field being
|
||||
actively removed.
|
||||
|
||||
Consequence: if a future apm release corrects the Copilot `mcpServers` omission, `reinject_mcp_servers()`
|
||||
and its call site become dead code and should be deleted — nothing else in this ADR depends on the
|
||||
@@ -166,13 +195,15 @@ A function name is stable enough to grep for; a line number in an ADR is stale b
|
||||
As originally executed, `sync-plugin-content.sh` wrote the merged hooks file to
|
||||
`plugins/<name>/hooks.json`. That path is scanned by nothing. Claude Code convention-scans
|
||||
`hooks/hooks.json`, and the "Plugin Directory Layout" table this ADR's own root-cause analysis
|
||||
quotes above says so on the same line it says "All content directories must be at the plugin root,
|
||||
not inside `.claude-plugin/`"
|
||||
(`plugins/kyberforge/docs/research/docs/claude-code-plugins/configuration.md:100`). The
|
||||
implementation read "at the plugin root" and dropped the file there; the row it was reading names
|
||||
`hooks/hooks.json`. So this ADR shipped with the contract quoted correctly in its diagnosis and
|
||||
violated in its output — the flat mirror bridged skills and agents into discovery and left hooks
|
||||
exactly as undiscoverable as before the fix.
|
||||
quotes above says so:
|
||||
`plugins/kyberforge/docs/research/docs/claude-code-plugins/configuration.md:100` is the row naming
|
||||
`hooks/hooks.json`, six lines below the table's preamble at `:94` — "All content directories must
|
||||
be at the plugin root, not inside `.claude-plugin/`". The two are not the same line; an earlier
|
||||
revision of this amendment said they were. The implementation read the preamble's "at the plugin
|
||||
root" and dropped the file there, without reading the row that names the path. So this ADR shipped
|
||||
with the contract quoted correctly in its diagnosis and violated in its output — the flat mirror
|
||||
bridged skills and agents into discovery and left hooks exactly as undiscoverable as before the
|
||||
fix.
|
||||
|
||||
The merged file therefore moves to `plugins/<name>/hooks/hooks.json`. A root-level `hooks.json`
|
||||
left over from a prior sync is stale output: a real sync deletes it, `--check` reports it as
|
||||
@@ -184,9 +215,99 @@ only those two grow a mirrored hooks file at all.
|
||||
This does **not** reopen the "patch `plugin.json` pointer fields" option rejected above. The move
|
||||
needs no `hooks` pointer in `plugin.json`: `hooks/hooks.json` *is* the convention path, so the
|
||||
host finds it by auto-discovery, exactly as it finds `skills/` and `agents/`. The rejection stands
|
||||
for the reason it was made — apm's `build_plugin_manifest` strips pointer fields unconditionally
|
||||
and is right to, because convention directories need no pointer. Writing to the convention path is
|
||||
what makes that premise true here rather than something to fight.
|
||||
for the reason it was made, once stated accurately — apm emits no pointer field for any of these,
|
||||
because `apm.yml` has no key that produces one, and none is needed when content sits at the
|
||||
convention path. (`hooks` was never in `build_plugin_manifest`'s strip list at all; see the
|
||||
corrected mechanism note under "Considered options".) Writing to the convention path is what makes
|
||||
the no-pointer premise true here rather than something to work around.
|
||||
|
||||
Read "the host finds it by auto-discovery" above as **Claude Code**, not both hosts. Copilot has no
|
||||
default for `hooks` and so discovers none — a real gap, examined and deliberately left open in the
|
||||
next amendment.
|
||||
|
||||
## Amendment (2026-08-14): no `hooks` pointer is re-injected for Copilot — the gap stays documented
|
||||
|
||||
PR #95's review found a third field, and it looks like the `mcpServers` amendment's exact twin:
|
||||
`plugins/kyberforge/docs/research/docs/github-copilot-plugins/configuration.md:47` types `hooks` as
|
||||
a `plugin.json` field, **"string or object"**, with **no default** — so Copilot has no convention
|
||||
path to scan — and `jq 'has("hooks")'` returns `false` for all six `plugins/*/.github/plugin/plugin.json`.
|
||||
Copilot therefore resolves **zero hooks from every plugin in this repo**. The facts are not in
|
||||
dispute; the remedy is.
|
||||
|
||||
State the mechanism correctly first, because it differs from `mcpServers` and the amendment above
|
||||
depends on that distinction. `mcpServers` is *actively removed* — `build_plugin_manifest` runs
|
||||
`manifest.pop("mcpServers", None)` on every Copilot manifest. `hooks` was **never in that strip
|
||||
list** (its field list is `("agents", "skills", "commands", "instructions")`, and the loop is dead
|
||||
code besides — see "Considered options"). This is an absence apm never fills, not a removal to
|
||||
reverse.
|
||||
|
||||
**Decision: do not re-inject. Document the gap.** The `mcpServers` exception was granted on three
|
||||
conditions, and `hooks` meets only two of them:
|
||||
|
||||
1. *A documented host schema field.* Met — `hooks` is in Copilot's own field table.
|
||||
2. *apm has no input that produces it.* Met — `apm.yml` has no key for it.
|
||||
3. *The payload is correct for the host regardless of content.* **Not met**, and this is the whole
|
||||
difference. `.mcp.json` is one host-agnostic format that both ecosystems read, so the string
|
||||
`".mcp.json"` is a true statement about the file no matter what is in it. Hooks have no such
|
||||
shared format: Claude Code reads
|
||||
`{"hooks": {"PreToolUse": [{"matcher": ..., "hooks": [...]}]}}` while Copilot requires
|
||||
`{"version": 1, "hooks": {"sessionStart": [{"type": "command", "bash": ..., "powershell": ...}]}}`
|
||||
— a mandatory `version`, lowercase and differently-named lifecycle events, and per-shell script
|
||||
keys. apm's exporter merges `.apm/hooks/*.json` into **exactly one** `hooks.json` with no
|
||||
per-target shaping (`_collect_hooks_from_apm`, `apm_cli/bundle/plugin_exporter.py`), and that one
|
||||
file also sits at Claude Code's convention path, where Claude Code will read it whatever it
|
||||
contains. So there is exactly one file and two incompatible readers of it.
|
||||
|
||||
A `hooks` pointer would therefore assert that a Claude-shaped file is Copilot-shaped. That trades an
|
||||
*incomplete* manifest for a *wrong* one, which is the opposite of the `mcpServers` amendment's
|
||||
reasoning ("shipping the manifest as apm produces it would ship a manifest known to be incomplete").
|
||||
|
||||
The "it changes nothing today, so it is zero-risk and correct-by-construction for the first real
|
||||
hook" argument does not survive the same check, in both halves. It is not inert today: both
|
||||
`hooks/hooks.json` files are `{"hooks": {}}`, which lacks the `version: 1` Copilot's schema
|
||||
requires, so a pointer would name a file invalid against the schema it is being pointed at from —
|
||||
a change from "declares no hooks" to "declares hooks, at an invalid file". And it is not
|
||||
correct-by-construction later: whoever writes the first real hook writes it in one of the two
|
||||
shapes, and the pointer is wrong in the Claude-shaped case (the case that actually happens, since
|
||||
Claude Code auto-discovers the same file and is what these hooks are authored against) while the
|
||||
Copilot-shaped case breaks Claude Code instead. No content makes both readers correct.
|
||||
|
||||
What would change this decision is upstream, not local: apm emitting a per-target hooks file (at
|
||||
which point a pointer names a file genuinely shaped for its reader), or the two hook schemas
|
||||
converging. Until then the honest artifact is a documented gap, recorded for authors in
|
||||
`plugins/kyberforge/docs/hooks.md` and pinned by a test asserting the Copilot manifest carries no
|
||||
`hooks` key — so that adding one is a deliberate act that has to confront the schema mismatch,
|
||||
rather than a plausible-looking one-liner nobody re-derives.
|
||||
|
||||
This does not weaken the `mcpServers` amendment. That exception was narrow on purpose, and this is
|
||||
what its third condition was for.
|
||||
|
||||
## Amendment (2026-08-14): symlinks under `.apm/` are dropped, and are now reported
|
||||
|
||||
apm's bundle exporter filters symlinks out of the bundle entirely — `f.is_file() and not
|
||||
f.is_symlink()` in `_collect_flat` and `_collect_recursive`, and the same test in
|
||||
`_collect_hooks_from_apm` (`apm_cli/bundle/plugin_exporter.py`). It emits no warning. A symlink
|
||||
placed under a plugin's `.apm/` therefore never reaches the mirror, and until now nothing said so.
|
||||
|
||||
This was **silent content loss, not drift**, and that distinction is why no existing gate caught it.
|
||||
Every other check in `sync-plugin-content.sh` compares the live mirror against a freshly synced
|
||||
copy — and both sides are built from that same bundle. The symlink is absent from both, they agree,
|
||||
and `--check` exits 0. There is no mismatch to detect, only an absence with nothing left to
|
||||
mismatch against. Reproduced on a fixture: `ln -s real.md link.md` under `.apm/skills/hello/`
|
||||
produced a mirror with no `link.md` and a `--check` at exit 0.
|
||||
|
||||
`check_apm_symlinks()` therefore reads the `.apm/` **source** tree directly — the only place the
|
||||
loss is visible — and reports each symlink in both modes, failing the run. It is reported rather
|
||||
than resolved: dereferencing and copying the target would make a real sync emit content the bundle
|
||||
does not contain, which is precisely the "reimplement apm's mapping outside apm" this ADR rejects.
|
||||
Telling the author is the in-contract half.
|
||||
|
||||
The scan covers only the `.apm/` directories apm's exporter actually reads
|
||||
(`agents`, `skills`, `prompts`, `commands`, `instructions`, `extensions`, `hooks`), and carves out
|
||||
`<category>/<name>/tests` to match the mirror's own exclusion — that subtree is not mirrored whether
|
||||
or not it holds a symlink, so nothing is lost there. The carve-out is depth-scoped for the same
|
||||
reason the `tests/` exclusion is: a symlink under `assets/templates/tests` sits in content the
|
||||
mirror does carry, and is reported.
|
||||
|
||||
## Consequences
|
||||
|
||||
|
||||
@@ -32,7 +32,9 @@ Two compilers produce the plugin roots you see in the tree:
|
||||
- **`apm pack` compiles the manifests** (ADR-0015). Per plugin: `.claude-plugin/plugin.json` and `.github/plugin/plugin.json`, both generated from `plugins/<name>/apm.yml`. Repo-wide, from the root `apm.yml`'s `marketplace:` block: `.claude-plugin/marketplace.json` (apm's `claude` output profile) and `.agents/plugins/marketplace.json` (its `codex` profile, a differently-shaped file). Those two are the only marketplace outputs apm has profiles for — the third root manifest, `.github/plugin/marketplace.json` (Copilot CLI's legacy path), is a byte-identical mirror of the Claude one maintained by `scripts/sync-marketplace-mirror.sh` and gated by the `check-marketplace-mirror-sync` pre-push hook.
|
||||
- **`scripts/sync-plugin-content.sh` compiles the content mirror** (ADR-0017). It wraps `apm pack --format plugin` and copies the resulting bundle's flat `agents/`, `skills/`, `commands/`, `instructions/`, `extensions/`, and merged `hooks/hooks.json` back to the plugin root. Claude Code's installer convention-scans those flat paths and has no `.apm/` awareness whatsoever, so the mirror exists solely to satisfy the host's discovery contract.
|
||||
|
||||
`.apm/` is the sole hand-edited authoring source for plugin content. An edit made in the flat mirror is discarded by the next sync and is reported as drift by the `check-plugin-content-sync` pre-push hook. Hand-authored material that is not an `.apm/` primitive — `README.md`, `docs/`, `bin/`, `sources.md`, and `.mcp.json` — lives at the plugin root and is untouched by either compiler.
|
||||
`.apm/` is the sole hand-edited authoring source for plugin content. An edit made in the flat mirror is discarded by the next sync and is reported as drift by the `check-plugin-content-sync` pre-push hook. Hand-authored material that is not an `.apm/` primitive — `README.md`, `docs/`, `bin/`, `sources.md`, `.mcp.json`, and per-plugin extras such as `plugins/git/config.example.json`, `plugins/gitea/references/` and `plugins/bin/evals/` — lives at the plugin **root** and is untouched by either compiler.
|
||||
|
||||
That immunity is positional, not by filename. Anything placed *inside* a mirrored directory is destroyed regardless of what it is: `sync_dir` runs `rm -rf "$dst"` before every copy, and `sync_hooks_json` does the same to `hooks/`. A hand-written `README.md` under `plugins/<name>/hooks/` or `plugins/<name>/skills/` is deleted by the next sync with no drift report, because a file with no `.apm/` counterpart is simply absent from the regenerated tree. This has already cost the repo one document — `plugins/kyberforge/hooks/README.md`, since restored to `plugins/kyberforge/docs/hooks.md`. Plugin-root documentation belongs in `docs/`.
|
||||
|
||||
## Governance layer
|
||||
|
||||
@@ -51,7 +53,9 @@ This repo uses two `AGENTS.md` files as the provider-agnostic source of always-o
|
||||
|
||||
Both `CLAUDE.md` files are thin adapters: they import from their respective `AGENTS.md` and add only Claude Code-specific syntax (`@import`, content index paths). They carry no original always-on content.
|
||||
|
||||
This repo also has a `CLAUDE.md` at its root — the Claude Code entry point for working in this repo. It imports `AGENTS.md` and `CONTEXT.md`, nothing more. This is distinct from `providers/claude-code/CLAUDE.md`, which is the global config deployed to `~/.claude/`.
|
||||
This repo also has a `CLAUDE.md` at its root — the Claude Code entry point for working in this repo. It imports `AGENTS.md` and nothing else; there is no `@CONTEXT.md` import. It is not import-only either: below the import sits a fenced `<!-- rtk-instructions v2 -->` … `<!-- /rtk-instructions -->` block carrying the RTK command-prefix convention, which is tool-specific content with no `AGENTS.md` source. This is distinct from `providers/claude-code/CLAUDE.md`, which is the global config deployed to `~/.claude/`.
|
||||
|
||||
`CONTEXT.md` is therefore **not** always-loaded. `AGENTS.md` instructs agents to read it at session start, which is a behavioural instruction, not an `@import` guarantee — `LESSONS.md`'s 2026-05-17 entry proposed adding the import and it was never applied. Treat that entry as open work rather than a record of a landed change.
|
||||
|
||||
## Provider model
|
||||
|
||||
@@ -59,4 +63,4 @@ This repo also has a `CLAUDE.md` at its root — the Claude Code entry point for
|
||||
|
||||
## Architectural decisions
|
||||
|
||||
Key hard-to-reverse decisions are recorded as ADRs in `docs/adr/`. See the index there for rationale on choices like the pull distribution model, copy-not-symlink coupling, and the two-tier CLAUDE.md structure.
|
||||
Key hard-to-reverse decisions are recorded as ADRs in `docs/adr/`. There is no index file — the directory holds 17 numbered ADRs whose filenames state their decision, so `ls docs/adr/` is the index. Read a superseding ADR before the one it supersedes: ADR-0015 (apm as the authoring source of truth) supersedes ADR-0001 and moots ADR-0006, and ADR-0017 corrects ADR-0015's host-discovery gap. Entry points for the structure described on this page: ADR-0002 (two-tier CLAUDE.md), ADR-0003 (AGENTS.md as the provider-agnostic entry point), ADR-0015 and ADR-0017 (the two compilers behind the plugin roots).
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "bin",
|
||||
"version": "1.1.1",
|
||||
"version": "1.1.2",
|
||||
"description": "A place for things to be binned",
|
||||
"author": {
|
||||
"name": "Defame1297",
|
||||
|
||||
+2
-11
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "bin",
|
||||
"version": "1.1.1",
|
||||
"version": "1.1.2",
|
||||
"description": "A place for things to be binned",
|
||||
"author": {
|
||||
"name": "Defame1297",
|
||||
@@ -17,14 +17,5 @@
|
||||
"tdd",
|
||||
"research"
|
||||
],
|
||||
"mcpServers": {
|
||||
"obsidian": {
|
||||
"args": [
|
||||
"@bitbonsai/mcpvault@latest",
|
||||
"docs/"
|
||||
],
|
||||
"command": "npx",
|
||||
"type": "stdio"
|
||||
}
|
||||
}
|
||||
"mcpServers": ".mcp.json"
|
||||
}
|
||||
@@ -33,10 +33,12 @@ copilot plugin install ./plugins/bin
|
||||
| Component | Path | Description |
|
||||
|---|---|---|
|
||||
| Skills | `.apm/skills/` → `skills/` | Slash commands available after install |
|
||||
| MCP servers | `.mcp.json` | The `obsidian` server (`npx @bitbonsai/mcpvault@latest docs/`), hand-authored at the plugin root and reinjected into both compiled `plugin.json` manifests |
|
||||
| MCP servers | `.mcp.json` | The `obsidian` server (`npx @bitbonsai/mcpvault@latest docs/`), hand-authored at the plugin root |
|
||||
|
||||
`.apm/` is the authoring source; `skills/` is the generated mirror plugin hosts scan (ADR-0017). This plugin ships no agents. It is the only plugin here with a non-empty `.mcp.json`, which is why its compiled manifests are the only ones carrying an `mcpServers` block.
|
||||
|
||||
The two compiled manifests get that block by different routes. `.claude-plugin/plugin.json` gets it from apm itself: `build_plugin_manifest`'s Claude branch calls `collect_mcp_servers`, which reads `.mcp.json`, sanitizes it, and inlines the resulting server objects. `.github/plugin/plugin.json` gets nothing from apm — the Copilot branch drops the field — so `scripts/sync-plugin-content.sh`'s `reinject_mcp_servers()` puts it back, as the **string `".mcp.json"`** rather than the resolved objects. Copilot's manifest schema types the field as "string or object — MCP server config path or inline definitions", and a path reference cannot carry a credential into a committed manifest. See ADR-0017's `mcpServers` amendment.
|
||||
|
||||
## Author
|
||||
|
||||
Defame1297
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
name: bin
|
||||
version: 1.1.1
|
||||
version: 1.1.2
|
||||
description: A place for things to be binned
|
||||
author:
|
||||
name: Defame1297
|
||||
|
||||
@@ -24,7 +24,12 @@ Provide the path to the repo root to audit when invoking.
|
||||
| `scripts/validate-drift.sh` | Resolves referenced npm/make commands and file paths against the repo |
|
||||
| `references/sources.md` | Provenance record — sources that informed this skill and which files each contributed to |
|
||||
| `scripts/README.md` | Directory documentation for `scripts/` |
|
||||
| `tests/README.md` | Bats test dependency and run instructions |
|
||||
| `tests/validate-secrets.bats` | Bats test suite for `scripts/validate-secrets.sh` |
|
||||
| `tests/validate-structure.bats` | Bats test suite for `scripts/validate-structure.sh` |
|
||||
| `tests/validate-drift.bats` | Bats test suite for `scripts/validate-drift.sh` |
|
||||
| `tests/README.md` | (source-only) Bats test dependency and run instructions |
|
||||
| `tests/validate-secrets.bats` | (source-only) Bats test suite for `scripts/validate-secrets.sh` |
|
||||
| `tests/validate-structure.bats` | (source-only) Bats test suite for `scripts/validate-structure.sh` |
|
||||
| `tests/validate-drift.bats` | (source-only) Bats test suite for `scripts/validate-drift.sh` |
|
||||
|
||||
Rows marked **(source-only)** exist in the authoring source (`.apm/skills/agentsmd-audit/`) but are
|
||||
not present in an installed plugin: `scripts/sync-plugin-content.sh` strips `<category>/<name>/tests`
|
||||
when it generates the flat mirror, because these are dev-time fixtures no plugin host needs to
|
||||
discover (ADR-0017). Run them from a repo checkout, not from an install.
|
||||
@@ -26,5 +26,10 @@ Provide the path to the provider-specific file to convert (and the target repo r
|
||||
| `references/sources.md` | Provenance record — the in-repo ADR precedent this skill's design is modeled on |
|
||||
| `scripts/validate-adapter.sh` | Self-check gate: reference to AGENTS.md present, no excessive duplication, adapter stays thin |
|
||||
| `scripts/README.md` | Directory documentation for `scripts/` |
|
||||
| `tests/README.md` | Bats test dependency and run instructions |
|
||||
| `tests/validate-adapter.bats` | Bats test suite for `scripts/validate-adapter.sh` |
|
||||
| `tests/README.md` | (source-only) Bats test dependency and run instructions |
|
||||
| `tests/validate-adapter.bats` | (source-only) Bats test suite for `scripts/validate-adapter.sh` |
|
||||
|
||||
Rows marked **(source-only)** exist in the authoring source (`.apm/skills/provider-adapter-author/`)
|
||||
but are not present in an installed plugin: `scripts/sync-plugin-content.sh` strips
|
||||
`<category>/<name>/tests` when it generates the flat mirror, because these are dev-time fixtures no
|
||||
plugin host needs to discover (ADR-0017). Run them from a repo checkout, not from an install.
|
||||
@@ -24,7 +24,12 @@ Provide the path to the repo root to audit when invoking.
|
||||
| `scripts/validate-drift.sh` | Resolves referenced npm/make commands and file paths against the repo |
|
||||
| `references/sources.md` | Provenance record — sources that informed this skill and which files each contributed to |
|
||||
| `scripts/README.md` | Directory documentation for `scripts/` |
|
||||
| `tests/README.md` | Bats test dependency and run instructions |
|
||||
| `tests/validate-secrets.bats` | Bats test suite for `scripts/validate-secrets.sh` |
|
||||
| `tests/validate-structure.bats` | Bats test suite for `scripts/validate-structure.sh` |
|
||||
| `tests/validate-drift.bats` | Bats test suite for `scripts/validate-drift.sh` |
|
||||
| `tests/README.md` | (source-only) Bats test dependency and run instructions |
|
||||
| `tests/validate-secrets.bats` | (source-only) Bats test suite for `scripts/validate-secrets.sh` |
|
||||
| `tests/validate-structure.bats` | (source-only) Bats test suite for `scripts/validate-structure.sh` |
|
||||
| `tests/validate-drift.bats` | (source-only) Bats test suite for `scripts/validate-drift.sh` |
|
||||
|
||||
Rows marked **(source-only)** exist in the authoring source (`.apm/skills/agentsmd-audit/`) but are
|
||||
not present in an installed plugin: `scripts/sync-plugin-content.sh` strips `<category>/<name>/tests`
|
||||
when it generates the flat mirror, because these are dev-time fixtures no plugin host needs to
|
||||
discover (ADR-0017). Run them from a repo checkout, not from an install.
|
||||
@@ -26,5 +26,10 @@ Provide the path to the provider-specific file to convert (and the target repo r
|
||||
| `references/sources.md` | Provenance record — the in-repo ADR precedent this skill's design is modeled on |
|
||||
| `scripts/validate-adapter.sh` | Self-check gate: reference to AGENTS.md present, no excessive duplication, adapter stays thin |
|
||||
| `scripts/README.md` | Directory documentation for `scripts/` |
|
||||
| `tests/README.md` | Bats test dependency and run instructions |
|
||||
| `tests/validate-adapter.bats` | Bats test suite for `scripts/validate-adapter.sh` |
|
||||
| `tests/README.md` | (source-only) Bats test dependency and run instructions |
|
||||
| `tests/validate-adapter.bats` | (source-only) Bats test suite for `scripts/validate-adapter.sh` |
|
||||
|
||||
Rows marked **(source-only)** exist in the authoring source (`.apm/skills/provider-adapter-author/`)
|
||||
but are not present in an installed plugin: `scripts/sync-plugin-content.sh` strips
|
||||
`<category>/<name>/tests` when it generates the flat mirror, because these are dev-time fixtures no
|
||||
plugin host needs to discover (ADR-0017). Run them from a repo checkout, not from an install.
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "git",
|
||||
"version": "1.3.2",
|
||||
"version": "1.3.3",
|
||||
"description": "Skills for working with Git \u2014 conventional commits, branch management, pull requests, and feature flow.",
|
||||
"author": {
|
||||
"name": "Defame1297",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "git",
|
||||
"version": "1.3.2",
|
||||
"version": "1.3.3",
|
||||
"description": "Skills for working with Git \u2014 conventional commits, branch management, pull requests, and feature flow.",
|
||||
"author": {
|
||||
"name": "Defame1297",
|
||||
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
name: git
|
||||
version: 1.3.2
|
||||
version: 1.3.3
|
||||
description: Skills for working with Git — conventional commits, branch management, pull requests, and feature flow.
|
||||
author:
|
||||
name: Defame1297
|
||||
|
||||
@@ -9,9 +9,10 @@ source_keys:
|
||||
- context7-websites-gitea
|
||||
- context7-gitea-tea-cli
|
||||
|
||||
disallowedTools: Edit, Write, NotebookEdit
|
||||
---
|
||||
|
||||
You are the orchestrator for the gitea plugin — a composable workflow dispatcher designed for other agents to invoke multi-step Gitea operations reliably. Your one job is routing and safety-gating: you do not call `mcp__gitea__*` tools yourself, you delegate to domain skills and enforce confirmation on destructive operations.
|
||||
You are the orchestrator for the gitea plugin — a composable workflow dispatcher designed for other agents to invoke multi-step Gitea operations reliably. Your one job is routing and safety-gating: you do not call `mcp__gitea__*` tools yourself, you delegate to domain skills and enforce confirmation on destructive operations. You never edit files. Every write you cause reaches its target through a domain skill's Gitea API call — never through an edit you make to the local working tree.
|
||||
|
||||
You resolve `owner`/`repo` once per session (via `git remote -v` on `origin`) and carry that forward as session context to every domain skill you dispatch to, rather than making each skill re-resolve it.
|
||||
|
||||
@@ -28,6 +29,7 @@ These are non-negotiable regardless of `confirm` or any skill-local override:
|
||||
- Issues and PRs share one number space. Before dispatching an operation keyed on a bare number, resolve whether it's an issue or a PR yourself (see Number resolution) — never infer the domain from operation phrasing alone.
|
||||
- `list_releases`/`list_tags` default to `per_page: 20` (other domains default to 30) with no server-side auto-pagination — when a caller needs a complete result set, loop `page` upward until a page returns fewer than `per_page` results before returning.
|
||||
- Never commit secrets, credentials, or environment-specific config into any file written via `gitea-files`.
|
||||
- You are read-only against the local working tree. Never create, edit, or delete a local file — not a manifest, not a config, not a scratch note. Local state is the caller's, and you only read it (e.g. `git remote -v`) to resolve context.
|
||||
|
||||
### Number resolution
|
||||
|
||||
@@ -69,7 +71,7 @@ When invoked, you:
|
||||
5. If the operation targets a bare number and the domain isn't specified, run Number resolution above before dispatch
|
||||
6. Invoke the appropriate domain skill via `Skill` with the operation, parameters, and resolved context (`owner`, `repo`)
|
||||
7. Catch and handle Gitea errors: disambiguate 404s (not-found vs. permission-hidden), retry transient failures, loop pagination for `list_releases`/`list_tags` until exhausted
|
||||
8. If recovery succeeds, continue; if not, return error structure with diagnostics and suggestions
|
||||
8. If recovery succeeds, continue; if not, return error structure with diagnostics and suggestions. If the blocker looks trivially fixable by a local edit — a stale `origin` URL, a malformed config, a missing label the repo obviously wants — name that fix in `suggestions` and stop. Do not act on it, and do not route it as a write operation the caller never asked for
|
||||
9. Aggregate all outputs and return as structured JSON
|
||||
|
||||
## Output
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "gitea",
|
||||
"version": "1.3.3",
|
||||
"version": "1.3.4",
|
||||
"description": "Skills for managing Gitea repositories \u2014 issues, pull requests, milestones, releases, and wikis.",
|
||||
"author": {
|
||||
"name": "Defame1297",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "gitea",
|
||||
"version": "1.3.3",
|
||||
"version": "1.3.4",
|
||||
"description": "Skills for managing Gitea repositories \u2014 issues, pull requests, milestones, releases, and wikis.",
|
||||
"author": {
|
||||
"name": "Defame1297",
|
||||
|
||||
@@ -9,9 +9,10 @@ source_keys:
|
||||
- context7-websites-gitea
|
||||
- context7-gitea-tea-cli
|
||||
|
||||
disallowedTools: Edit, Write, NotebookEdit
|
||||
---
|
||||
|
||||
You are the orchestrator for the gitea plugin — a composable workflow dispatcher designed for other agents to invoke multi-step Gitea operations reliably. Your one job is routing and safety-gating: you do not call `mcp__gitea__*` tools yourself, you delegate to domain skills and enforce confirmation on destructive operations.
|
||||
You are the orchestrator for the gitea plugin — a composable workflow dispatcher designed for other agents to invoke multi-step Gitea operations reliably. Your one job is routing and safety-gating: you do not call `mcp__gitea__*` tools yourself, you delegate to domain skills and enforce confirmation on destructive operations. You never edit files. Every write you cause reaches its target through a domain skill's Gitea API call — never through an edit you make to the local working tree.
|
||||
|
||||
You resolve `owner`/`repo` once per session (via `git remote -v` on `origin`) and carry that forward as session context to every domain skill you dispatch to, rather than making each skill re-resolve it.
|
||||
|
||||
@@ -28,6 +29,7 @@ These are non-negotiable regardless of `confirm` or any skill-local override:
|
||||
- Issues and PRs share one number space. Before dispatching an operation keyed on a bare number, resolve whether it's an issue or a PR yourself (see Number resolution) — never infer the domain from operation phrasing alone.
|
||||
- `list_releases`/`list_tags` default to `per_page: 20` (other domains default to 30) with no server-side auto-pagination — when a caller needs a complete result set, loop `page` upward until a page returns fewer than `per_page` results before returning.
|
||||
- Never commit secrets, credentials, or environment-specific config into any file written via `gitea-files`.
|
||||
- You are read-only against the local working tree. Never create, edit, or delete a local file — not a manifest, not a config, not a scratch note. Local state is the caller's, and you only read it (e.g. `git remote -v`) to resolve context.
|
||||
|
||||
### Number resolution
|
||||
|
||||
@@ -69,7 +71,7 @@ When invoked, you:
|
||||
5. If the operation targets a bare number and the domain isn't specified, run Number resolution above before dispatch
|
||||
6. Invoke the appropriate domain skill via `Skill` with the operation, parameters, and resolved context (`owner`, `repo`)
|
||||
7. Catch and handle Gitea errors: disambiguate 404s (not-found vs. permission-hidden), retry transient failures, loop pagination for `list_releases`/`list_tags` until exhausted
|
||||
8. If recovery succeeds, continue; if not, return error structure with diagnostics and suggestions
|
||||
8. If recovery succeeds, continue; if not, return error structure with diagnostics and suggestions. If the blocker looks trivially fixable by a local edit — a stale `origin` URL, a malformed config, a missing label the repo obviously wants — name that fix in `suggestions` and stop. Do not act on it, and do not route it as a write operation the caller never asked for
|
||||
9. Aggregate all outputs and return as structured JSON
|
||||
|
||||
## Output
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
name: gitea
|
||||
version: 1.3.3
|
||||
version: 1.3.4
|
||||
description: Skills for managing Gitea repositories — issues, pull requests, milestones, releases, and wikis.
|
||||
author:
|
||||
name: Defame1297
|
||||
|
||||
@@ -5,9 +5,11 @@ description: Orchestrates apm package/marketplace operations for other agents. I
|
||||
|
||||
source_keys:
|
||||
- context7-microsoft-apm
|
||||
|
||||
disallowedTools: Edit, Write, NotebookEdit
|
||||
---
|
||||
|
||||
You are the orchestrator for apm package/marketplace operations — a composable workflow dispatcher designed for other agents to invoke multi-step `apm` operations reliably, especially the same operation repeated across several packages in a monorepo-hybrid layout. Your one job is routing and safety-gating: you do not decide manifest content yourself, you delegate to `apm-workflow` and enforce confirmation on irreversible operations.
|
||||
You are the orchestrator for apm package/marketplace operations — a composable workflow dispatcher designed for other agents to invoke multi-step `apm` operations reliably, especially the same operation repeated across several packages in a monorepo-hybrid layout. Your one job is routing and safety-gating: you do not decide manifest content yourself, you delegate to `apm-workflow` and enforce confirmation on irreversible operations. You never edit files. Every manifest or primitive that changes under your dispatch is written by `apm-workflow` or by `apm` itself — never by an edit you make.
|
||||
|
||||
You resolve the package root once per dispatched operation (the directory containing that package's `apm.yml`) and carry it forward as session context rather than making every call re-resolve it.
|
||||
|
||||
@@ -21,6 +23,7 @@ These are non-negotiable regardless of `confirm` or any skill-local override:
|
||||
- `apm.yml`'s `type:` field constrains what `.apm/` may contain — when scaffolding (`init-package`), set `type:` before any primitive content is added; do not defer it.
|
||||
- A clean plain `apm audit` is not a CI-equivalent pass — if the caller's intent is a CI gate, dispatch `audit-ci`, not `audit`.
|
||||
- Check the `apm experimental enable registries` precondition before dispatching any operation that depends on a named registry, and fail with a clear diagnostic rather than silently no-op'ing like apm itself does — see apm-workflow/SKILL.md Gotchas for the underlying constraint.
|
||||
- You are read-only against the working tree. Never create, edit, or delete a file — not an `apm.yml`, not a `.apm/` primitive, not compiled output, not a scratch note. `edit-config` is an operation you *route* to `apm-workflow`, never one you perform: dispatching it is allowed only when the caller asked for that edit, never as your own repair of something you noticed.
|
||||
|
||||
When invoked, you:
|
||||
1. Parse the incoming workflow request (operation type, parameters, target package(s), context overrides)
|
||||
@@ -52,7 +55,7 @@ When invoked, you:
|
||||
4. Verify `apm --version` succeeds; if not, fail with a diagnostic pointing to `apm-install`
|
||||
5. Invoke `apm-workflow` via `Skill` with the resolved action, `package_root`, and parameters
|
||||
6. If fanning across multiple packages, dispatch independent packages in parallel when no shared state or ordering dependency exists between them; loop package-by-package (strictly sequential) only for packages with a real dependency on another package's completion. Either way, collect per-package results and failures rather than aborting on the first failure
|
||||
7. Catch and handle apm errors: retry once for a dependency-not-yet-scaffolded failure after the caller confirms the dependency exists; otherwise return error structure with diagnostics
|
||||
7. Catch and handle apm errors: retry once for a dependency-not-yet-scaffolded failure after the caller confirms the dependency exists; otherwise return error structure with diagnostics. If the failure looks trivially fixable by a one-line manifest edit — a missing `category:`, a typo'd `source:`, a version that disagrees between a package and the catalog — name that fix in `suggestions` and stop. Do not apply it yourself and do not self-dispatch an `edit-config` to apply it
|
||||
8. Aggregate all outputs and return as structured JSON
|
||||
|
||||
## Output
|
||||
|
||||
@@ -7,11 +7,14 @@ plugin/APM scope, or a Claude Code and Copilot file pair at project/user scope.
|
||||
|
||||
At **plugin/APM scope**, accepts the single `.apm/agents/<name>.agent.md` file — there is no
|
||||
counterpart. Structural checks via `validate.sh` hard-`FAIL` any frontmatter field outside the
|
||||
vendor-neutral allowlist (`name`, `description`, `model`, `source_keys` — the last for
|
||||
provenance tracking, checked separately by `validate-provenance.sh` against `sources.md`; see
|
||||
ADR-0016), since `apm compile`
|
||||
copies frontmatter verbatim to both harnesses and an unsafe field can't be silently dropped for
|
||||
just one of them.
|
||||
vendor-neutral allowlist, since `apm compile` copies frontmatter verbatim to both harnesses and an
|
||||
unsafe field can't be silently dropped for just one of them. The allowlist itself lives in the
|
||||
`apm-agent-allowlist` section of `references/field-inventory.md` and is read from there as data —
|
||||
consult that section rather than any restatement of it, including this one. As of 2026-08-14 it
|
||||
admits `name`, `description`, `model`, `source_keys`, and `disallowedTools`; `source_keys` is
|
||||
provenance metadata checked separately by `validate-provenance.sh` against `sources.md`, and
|
||||
`disallowedTools` is admitted because a denylist survives verbatim copy where the `tools` allowlist
|
||||
does not (ADR-0016 and its 2026-08-14 amendment).
|
||||
|
||||
At **project/user scope**, accepts either file in a CC `.md` / Copilot `.agent.md` pair, derives
|
||||
the counterpart automatically, and validates both. Runs structural checks via `validate.sh`
|
||||
@@ -46,12 +49,17 @@ Pass the path to either agent file as the argument.
|
||||
| `assets/vale/styles/KyberforgeCopilot/ProactivePhrase.yml` | Flags CC-specific "Use proactively" phrasing with no effect in Copilot descriptions |
|
||||
| `references/README.md` | Directory documentation for references/ |
|
||||
| `references/description-quality.md` | Qualitative guide for borderline description findings |
|
||||
| `references/field-inventory.md` | Authoritative list of valid CC and Copilot agent fields |
|
||||
| `references/field-inventory.md` | Authoritative field lists read as data by `validate.sh`: valid CC and Copilot agent fields, and the vendor-neutral plugin/APM-scope allowlist |
|
||||
| `references/sources.md` | Research provenance for skill content |
|
||||
| `scripts/README.md` | Directory documentation for scripts/ |
|
||||
| `scripts/validate.sh` | Structural validation script for agent file pairs |
|
||||
| `scripts/validate-provenance.sh` | Provenance chain validation script for agent pairs against `sources.md` (plugin root) |
|
||||
| `scripts/vale-wrap.sh` | Drop-in `vale` wrapper that works around a frontmatter-description NLP scope limitation |
|
||||
| `tests/README.md` | Bats test dependency and run instructions |
|
||||
| `tests/validate.bats` | Bats tests for validate.sh |
|
||||
| `tests/validate-provenance.bats` | Bats tests for validate-provenance.sh |
|
||||
| `tests/README.md` | (source-only) Bats test dependency and run instructions |
|
||||
| `tests/validate.bats` | (source-only) Bats tests for validate.sh |
|
||||
| `tests/validate-provenance.bats` | (source-only) Bats tests for validate-provenance.sh |
|
||||
|
||||
Rows marked **(source-only)** exist in the authoring source (`.apm/skills/agent-audit/`) but are
|
||||
not present in an installed plugin: `scripts/sync-plugin-content.sh` strips
|
||||
`<category>/<name>/tests` when it generates the flat mirror, because these are dev-time fixtures no
|
||||
plugin host needs to discover (ADR-0017). Run them from a repo checkout, not from an install.
|
||||
@@ -44,13 +44,13 @@ The script accepts either the CC file, the Copilot file, or (at plugin/APM scope
|
||||
|
||||
At **project/user scope** it derives the counterpart and runs the existing pair-based checks. Note FAILs and SUGGESTIONs for the `### Structure` and `### Provider safety` report dimensions. Findings about missing fields, bad name format, empty body, or missing frontmatter → `### Structure`. Findings about CC-only fields in a Copilot file, Copilot-only fields in a CC file, body length, or subagent-unavailable tools → `### Provider safety`. A missing counterpart file → `### Pair consistency`.
|
||||
|
||||
At **plugin/APM scope** there is no counterpart — the script instead checks the single file's frontmatter against the `apm-agent-allowlist` in `references/field-inventory.md` (`name`, `description`, `model`, `source_keys` — nothing else; `source_keys` is provenance metadata, not a provider-specific field, and is validated separately by `validate-provenance.sh` against `sources.md`). Findings about missing fields, bad name format, name/filename-stem mismatch, empty body, or missing frontmatter → `### Structure`, same as project/user scope. Findings about any field outside the allowlist (e.g. `tools`, or any Claude-only/Copilot-only field carried over from a hand-edit) and body length → `### Provider safety` — but the dimension's meaning shifts here: it is no longer a CC-vs-Copilot field-leakage check, it's a vendor-neutral-field-allowlist check, since `apm compile` verbatim-copies this file's frontmatter to every target and there is no per-target integrator to reconcile a CC-only or Copilot-only field (ADR-0016). `### Pair consistency` never applies at this scope — the script never emits a missing-counterpart FAIL here, because there is nothing to pair by design.
|
||||
At **plugin/APM scope** there is no counterpart — the script instead checks the single file's frontmatter against the `apm-agent-allowlist` in `references/field-inventory.md`. Read that section for the current list rather than reciting one here; it is the authoritative source and it changes. As of 2026-08-14 it is `name`, `description`, `model`, `source_keys`, `disallowedTools` — `source_keys` is provenance metadata, not a provider-specific field, and is validated separately by `validate-provenance.sh` against `sources.md`; `disallowedTools` is a denylist, admitted because denying a tool by name is safe under `apm compile`'s verbatim copy in a way the `tools` allowlist is not (ADR-0016's 2026-08-14 amendment, and the rationale recorded alongside the list itself). Findings about missing fields, bad name format, name/filename-stem mismatch, empty body, or missing frontmatter → `### Structure`, same as project/user scope. Findings about any field outside the allowlist (e.g. `tools`, or any Claude-only/Copilot-only field carried over from a hand-edit) and body length → `### Provider safety` — but the dimension's meaning shifts here: it is no longer a CC-vs-Copilot field-leakage check, it's a vendor-neutral-field-allowlist check, since `apm compile` verbatim-copies this file's frontmatter to every target and there is no per-target integrator to reconcile a CC-only or Copilot-only field (ADR-0016). `### Pair consistency` never applies at this scope — the script never emits a missing-counterpart FAIL here, because there is nothing to pair by design.
|
||||
|
||||
`vale-wrap.sh` ships inside this skill's own `scripts/` — resolve it relative to this skill's directory the same way `scripts/validate.sh` is resolved above, so the invocation works whether this skill is running from this repo or from an installed plugin cache. Pass no `--config`: handed none, the wrapper loads its own sibling `assets/vale/.vale.ini`, located from the script's path rather than from the cwd. Adding an explicit relative `--config` breaks exactly the case the self-location covers — a resolved script path plus an unresolved config path yields `E100 Runtime error ... does not exist`, exit 2, which the fallback below then misreads as "vale unavailable". At project/user scope, run it against both files of the pair (not just the one passed in); at plugin/APM scope, run it against the single file. `Kyberforge` applies to all of these files via the `**/agents/*.md` glob; `KyberforgeCopilot` applies to any `*.agent.md` file — including the plugin/APM-scope file, which already has that extension — via the `**/*.agent.md` glob, since its one rule (`Use proactively`) flags CC-specific phrasing that's meaningless in a vendor-neutral or Copilot description. Every Vale alert is a `FAIL` — all rules are graded `error` — so report each one in the `### Description` / `### Body` dimensions citing its rule ID (e.g. `KyberforgeCopilot.ProactivePhrase`). Skip and fall back to Step 2 judgment if the `vale` binary is unavailable. If Vale reports `0 files` scanned, treat the pass as NOT RUN — not as clean — and fall back to full Step 2 judgment for the dimensions it would have covered.
|
||||
|
||||
`validate-provenance.sh` operates at plugin/APM scope only — it walks up from the agent file's directory the same way `validate.sh` does (nearest ancestor `apm.yml` with a top-level `type:` field; skip a `type:`-less marketplace-only `apm.yml`; stop at `.git` or the filesystem root) and exits 0 silently if that walk doesn't land on a package root, or when no provenance data exists. When it does apply, it validates the chain between the single file's own `source_keys` and the package-scoped `sources.md` (package root — see ADR-0010). Note FAILs from this script for the `### Provenance` dimension — surface them verbatim with Why and Fix.
|
||||
|
||||
If the scripts cannot run (Bash denied, python3 unavailable), perform checks manually. At project/user scope: counterpart file exists, required fields present (`name`, `description`, non-empty body), `name` is kebab-case, Copilot CLI `.agent.md` `name` must match filename stem (CC files are exempt — the CC platform does not require name to match filename), no `FILL IN:` placeholders, no CC-only fields in Copilot file, no Copilot-only fields in CC file (read `references/field-inventory.md` for the authoritative field lists). At plugin/APM scope: required fields present (`name`, `description`, non-empty body), `name` is kebab-case and matches the filename stem, no `FILL IN:` placeholders, no frontmatter field outside `name`/`description`/`model`/`source_keys` (read the `apm-agent-allowlist` section of `references/field-inventory.md`; `source_keys` carries provenance metadata, checked separately by `validate-provenance.sh` against `sources.md`).
|
||||
If the scripts cannot run (Bash denied, python3 unavailable), perform checks manually. At project/user scope: counterpart file exists, required fields present (`name`, `description`, non-empty body), `name` is kebab-case, Copilot CLI `.agent.md` `name` must match filename stem (CC files are exempt — the CC platform does not require name to match filename), no `FILL IN:` placeholders, no CC-only fields in Copilot file, no Copilot-only fields in CC file (read `references/field-inventory.md` for the authoritative field lists). At plugin/APM scope: required fields present (`name`, `description`, non-empty body), `name` is kebab-case and matches the filename stem, no `FILL IN:` placeholders, no frontmatter field outside the allowlist — read the `apm-agent-allowlist` section of `references/field-inventory.md` for it, do not work from memory (`source_keys` carries provenance metadata, checked separately by `validate-provenance.sh` against `sources.md`).
|
||||
|
||||
## Step 2 — Qualitative checks
|
||||
|
||||
|
||||
@@ -25,4 +25,25 @@ target disable-model-invocation user-invocable mcp-servers metadata
|
||||
|
||||
## apm-agent-allowlist
|
||||
|
||||
name description model source_keys
|
||||
name description model source_keys disallowedTools
|
||||
|
||||
Parsing note: `validate.sh` reads the **first** non-empty, non-`#`, non-`---` line under each
|
||||
heading as a whitespace-separated token list, and stops there. Keep the token line immediately
|
||||
below its heading; explanatory prose goes after it, as here.
|
||||
|
||||
Why `disallowedTools` is on a list that is otherwise vendor-neutral, when `tools` is not
|
||||
(ADR-0016 and its 2026-08-14 amendment): the two are not symmetric. `tools` is an **allowlist**
|
||||
whose vocabulary differs per harness — Claude Code names its own tools, Copilot CLI uses aliases
|
||||
(`execute`/`read`/`edit`/`search`/`agent`/`web`) — so a value correct for one is wrong for the
|
||||
other, and `apm compile` copies frontmatter verbatim with no per-target integrator to reconcile
|
||||
them. `disallowedTools` is a **denylist**, and denying by name is safe under verbatim copy: a name
|
||||
the other harness does not recognise denies nothing, so the worst case is that the fence is absent
|
||||
there, never that the wrong capability is granted. Claude Code honours it for plugin subagents —
|
||||
`docs/research/docs/claude-code-plugins/agent-definition.md:99` names the fields plugin agents
|
||||
silently ignore (`hooks`, `mcpServers`, `permissionMode`) and `disallowedTools` is not among them.
|
||||
|
||||
`disallowedTools` also appears in `claude-code-only-fields` above, and that stays correct: at
|
||||
project/user scope it is still a Claude-only field and must not appear in a Copilot `.agent.md`.
|
||||
The two lists answer different questions — "may this field cross the CC/Copilot file boundary" for
|
||||
a real pair, versus "is this field safe under verbatim copy to every target" for a single
|
||||
vendor-neutral APM file.
|
||||
@@ -8,9 +8,11 @@ Usage: validate.sh <agent-file>
|
||||
Validate an agent definition file against the agent definition spec.
|
||||
|
||||
At plugin/APM scope, <agent-file> is a single vendor-neutral
|
||||
.apm/agents/<name>.agent.md file (frontmatter allowlist: name, description,
|
||||
model — no counterpart file). At project or user scope, <agent-file> is
|
||||
either half of a Claude Code .md / Copilot .agent.md pair.
|
||||
.apm/agents/<name>.agent.md file with no counterpart. Its frontmatter allowlist
|
||||
is not restated here: it is read at load time from the apm-agent-allowlist
|
||||
section of references/field-inventory.md, which is the authoritative list.
|
||||
At project or user scope, <agent-file> is either half of a Claude Code .md /
|
||||
Copilot .agent.md pair.
|
||||
|
||||
Arguments:
|
||||
agent-file Path to the agent file (or either half of a project/user-scope pair).
|
||||
@@ -241,10 +243,13 @@ def check_apm_agent_file(fpath, allowlist, stem):
|
||||
fail(f"frontmatter still contains template HTML comments (<!-- ... -->) "
|
||||
f"— delete them before shipping — {local_fname}")
|
||||
|
||||
# Allowlist: only name/description/model may appear — no tools, no
|
||||
# Claude-only or Copilot-only fields. apm compile verbatim-copies
|
||||
# frontmatter to every target, so anything else is unsafe on at least
|
||||
# one harness (ADR-0016).
|
||||
# Allowlist: the permitted keys are data, read at load time from
|
||||
# references/field-inventory.md's `## apm-agent-allowlist` section — do not
|
||||
# restate them here, or this comment goes stale the next time that line
|
||||
# changes. apm compile verbatim-copies frontmatter to every target, so a key
|
||||
# outside the list is unsafe on at least one harness (ADR-0016). Note the
|
||||
# list admits denylist-shaped restrictions (disallowedTools) but never
|
||||
# allowlist-shaped ones (tools), whose value shape differs per harness.
|
||||
fm_keys = get_frontmatter_keys(fm)
|
||||
for key in sorted(fm_keys):
|
||||
if key not in allowlist:
|
||||
|
||||
@@ -38,8 +38,16 @@ bash scripts/new-agent.sh security-reviewer ~
|
||||
| `assets/templates/claude-code.md` | Annotated Claude Code agent definition template (project/user scope) |
|
||||
| `assets/templates/copilot.agent.md.template` | Annotated Copilot CLI agent definition template (project/user scope) |
|
||||
| `assets/templates/apm-agent.md` | Annotated vendor-neutral APM agent definition template (plugin/APM scope) |
|
||||
| `tests/new-agent.bats` | bats tests for `scripts/new-agent.sh` |
|
||||
| `tests/new-agent.bats` | (source-only) bats tests for `scripts/new-agent.sh` |
|
||||
| `assets/README.md` | Directory meta-documentation for assets/ |
|
||||
| `references/README.md` | Directory meta-documentation for references/ |
|
||||
| `scripts/README.md` | Directory meta-documentation for scripts/ |
|
||||
| `tests/README.md` | bats dependency instructions and run command |
|
||||
| `tests/README.md` | (source-only) bats dependency instructions and run command |
|
||||
|
||||
Rows marked **(source-only)** exist in the authoring source (`.apm/skills/agent-author/`) but are
|
||||
not present in an installed plugin: `scripts/sync-plugin-content.sh` strips
|
||||
`<category>/<name>/tests` when it generates the flat mirror, because these are dev-time fixtures no
|
||||
plugin host needs to discover (ADR-0017). Run them from a repo checkout, not from an install. The
|
||||
`assets/templates/` rows above are unaffected — the exclusion is depth-scoped to
|
||||
`<category>/<name>/tests`, so template trees that themselves contain a `tests/` directory ship
|
||||
intact.
|
||||
@@ -29,7 +29,9 @@ metadata:
|
||||
## Gotchas
|
||||
|
||||
- At plugin/APM scope, bump the resolved package's `apm.yml` `version` after every change — minor for a new agent, patch for a fix. Consumers compare this version to detect updates; skipping it hides the change.
|
||||
- At plugin/APM scope, `tools` and all Claude-only fields (`isolation`, `maxTurns`, `effort`, `memory`, `permissionMode`, `disallowedTools`, `skills`, `color`, `initialPrompt`, `background`, `hooks`, `mcpServers`) are omitted entirely, not merely restricted (ADR-0016: `apm compile` copies frontmatter verbatim to both harnesses with no per-target integrator, so a harness-specific value is wrong on at least one). Only project/user scope supports these fields.
|
||||
- At plugin/APM scope, `tools` and all Claude-only fields (`isolation`, `maxTurns`, `effort`, `memory`, `permissionMode`, `skills`, `color`, `initialPrompt`, `background`, `hooks`, `mcpServers`) are omitted entirely, not merely restricted (ADR-0016: `apm compile` copies frontmatter verbatim to both harnesses with no per-target integrator, so a harness-specific value is wrong on at least one). Only project/user scope supports these fields.
|
||||
- `disallowedTools` is the one exception, on **shape**, not favouritism. `tools` is an *allowlist* whose vocabulary differs per harness (Claude tool names vs Copilot's `execute`/`read`/`edit`/`search`/`agent`/`web`), so verbatim copy makes one value wrong on one target. A *denylist* cannot fail that way: an unrecognised name denies nothing, so the worst case is a missing fence, never a wrong grant. Claude Code honours it for plugin subagents — `docs/research/docs/claude-code-plugins/agent-definition.md:99` lists the three fields plugin agents ignore (`hooks`, `mcpServers`, `permissionMode`) and this is not one. Write it on every read-only plugin-scope agent (ADR-0016's 2026-08-14 amendment).
|
||||
- That fence is partial: it denies only the tools it names. It does not deny `Bash`, which a plugin-scope agent with no `tools` inherits, so a shell redirect still writes. Say the agent is read-only in the body too.
|
||||
- An `apm.yml` with no top-level `type:` field is a marketplace-only manifest, not a package root — the walk-up skips it and keeps going.
|
||||
- `AskUserQuestion`, `EnterPlanMode`, `ExitPlanMode`, `ScheduleWakeup`, and `WaitForMcpServers` are never available to any subagent regardless of the `tools` field. Exception: `ExitPlanMode` is available when the parent session runs in `permissionMode: plan`.
|
||||
- Duplicate `name` values in the same scope: Claude Code silently discards one without warning. Always verify uniqueness before shipping.
|
||||
@@ -90,7 +92,7 @@ The script is file-by-file no-op — it skips any file that already exists.
|
||||
|
||||
### Step 2 — Fill in the agent file(s)
|
||||
|
||||
**At plugin/APM scope**, there is exactly one file: `<package-root>/.apm/agents/<name>.agent.md`. Frontmatter carries ONLY `name`, `description`, optionally `model`, and optionally `source_keys` (provenance metadata, not a runtime field — see the template) — never `tools` or the other Claude-only fields listed in Gotchas (ADR-0016). Fill in `name`, `description`, `model`, and the system prompt body per the guidance below; the rest of this step's field-by-field guidance (tools, maxTurns, effort, memory, isolation, disallowedTools, skills, color, initialPrompt, background) is project/user scope only. Skip Step 3 and go to Step 4.
|
||||
**At plugin/APM scope**, there is exactly one file: `<package-root>/.apm/agents/<name>.agent.md`. Its frontmatter allowlist is the `apm-agent-allowlist` section of `agent-audit`'s `references/field-inventory.md`, read from there as data: `name`, `description`, `model`, `source_keys` (provenance metadata, not a runtime field — see the template), and `disallowedTools` for a read-only agent. Never `tools` or the other Claude-only fields listed in Gotchas (ADR-0016). Fill those in plus the system prompt body per the guidance below; the rest of this step's field-by-field guidance is project/user scope only. Skip Step 3 and go to Step 4.
|
||||
|
||||
**At project/user scope**, continue below to fill in both provider files — this step covers the Claude Code file (`<name>.md`); Step 3 covers the Copilot file.
|
||||
|
||||
@@ -106,13 +108,14 @@ Open the scaffolded Claude Code file. Replace every `FILL IN:` placeholder. **Re
|
||||
|
||||
**`tools`** (project/user scope only — never at plugin/APM scope) — restrict to what the agent actually needs. Omit to inherit all tools. Use `Agent(type1,type2)` to limit which subagent types this agent can spawn; omit `Agent` entirely to prevent spawning.
|
||||
|
||||
**Optional fields worth considering (project/user scope only — never at plugin/APM scope):**
|
||||
**`disallowedTools`** (all scopes, including plugin/APM) — denylist applied before `tools` and taking precedence over it; supports `mcp__<server>`, `mcp__<server>__*`, and `mcp__*` globs. `api-reference.md:40` types it `string / list` and `agent-definition.md:71` types it `string[]`, so a YAML list or a delimited string both work; this repo's plugin-scope agents use the comma-separated string (`disallowedTools: Edit, Write, NotebookEdit`) — match that.
|
||||
|
||||
**Optional fields worth considering (project/user scope only — never at plugin/APM scope, with the exception of `model`, which is allowed at every scope):**
|
||||
- `model`: set when this agent needs a different capability tier (`haiku` for fast tasks, `opus` for deep reasoning)
|
||||
- `maxTurns`: set a cap to prevent runaway agents on bounded tasks
|
||||
- `effort`: set to `low` for single-lookup tasks, `high` or above for deep reasoning or multi-file analysis — overrides session effort level; omit to inherit
|
||||
- `memory`: `user`, `project`, or `local` — only when cross-session state is genuinely needed
|
||||
- `isolation: worktree` — only when the agent modifies files and needs an isolated copy
|
||||
- `disallowedTools`: space-separated denylist applied before `tools`; supports `mcp__*` glob patterns (e.g. `disallowedTools: mcp__filesystem__*`)
|
||||
- `skills`: list of skill names preloaded at agent startup — different from the `source_keys` metadata field
|
||||
- `color`: UI color for the agent tile (`red`, `blue`, `green`, `yellow`, `purple`, `orange`, `pink`, `cyan`)
|
||||
- `initialPrompt`: auto-submitted as the first turn when this agent activates as the main session thread; only set when this agent is intended for main-thread activation
|
||||
@@ -152,7 +155,7 @@ Skip this step entirely at plugin/APM scope — there is no separate Copilot fil
|
||||
|
||||
**`source_keys`** — add the same top-level list as the CC file when research sources were used. Omit when no research was used.
|
||||
|
||||
**Remove all template documentation comments from the YAML frontmatter after filling in required fields** — these are marked with `<!--` and `-->` and must be deleted before shipping.
|
||||
**Delete the `<!-- -->` template comments from the frontmatter**, as in Step 2.
|
||||
|
||||
The system prompt body should match the Claude Code version — the agent's task definition is the same across providers.
|
||||
|
||||
@@ -183,37 +186,32 @@ If no research sources are in context, delete `sources.md`.
|
||||
|
||||
### Step 5 — Validate and close
|
||||
|
||||
Run this checklist before invoking the audit:
|
||||
Run this checklist before invoking the audit.
|
||||
|
||||
**Every file, at every scope:**
|
||||
- [ ] `name` present and kebab-case; `description` present
|
||||
- [ ] System prompt body present and non-empty
|
||||
- [ ] No `FILL IN:` placeholders and no `<!-- -->` template comments remain
|
||||
|
||||
**Plugin/APM scope — single file (`<name>.agent.md`):**
|
||||
- [ ] `name` field present, kebab-case, unique in scope
|
||||
- [ ] `description` field present and action-first
|
||||
- [ ] Frontmatter contains ONLY `name`, `description`, and optionally `model` (plus `source_keys` if research-sourced) — no `tools`, `isolation`, `maxTurns`, `effort`, `memory`, `permissionMode`, `disallowedTools`, `skills`, `color`, `initialPrompt`, `background`, `hooks`, or `mcpServers`
|
||||
- [ ] System prompt body present and non-empty
|
||||
- [ ] No `FILL IN:` placeholders remain
|
||||
- [ ] No `<!-- -->` template comments remain in frontmatter
|
||||
- [ ] `name` unique in scope; `description` action-first
|
||||
- [ ] Every frontmatter field is in the `apm-agent-allowlist` section of `agent-audit`'s `references/field-inventory.md` — the single source of truth, read as data by `validate.sh`. As of 2026-08-14: `name`, `description`, `model`, `source_keys`, `disallowedTools`. Nothing else — in particular no `tools`
|
||||
- [ ] A read-only agent carries `disallowedTools` **and** says so in the body
|
||||
|
||||
**Project/user scope — Claude Code file (`<name>.md`):**
|
||||
- [ ] `name` field present, kebab-case, unique in scope
|
||||
- [ ] `description` field present and action-first
|
||||
- [ ] System prompt body present and non-empty
|
||||
- [ ] No `FILL IN:` placeholders remain
|
||||
- [ ] No `<!-- -->` template comments remain in frontmatter
|
||||
- [ ] `name` unique in scope; `description` action-first
|
||||
|
||||
**Project/user scope — Copilot CLI file (`<name>.agent.md`):**
|
||||
- [ ] File extension is `.agent.md` (not `.md`)
|
||||
- [ ] `name` field matches the filename stem (e.g. `name: my-agent` in `my-agent.agent.md`)
|
||||
- [ ] `description` field present
|
||||
- [ ] `name` matches the filename stem (e.g. `name: my-agent` in `my-agent.agent.md`)
|
||||
- [ ] No Claude Code-only fields (`maxTurns`, `isolation`, `memory`, `permissionMode`, `effort`, `hooks`, `mcpServers`)
|
||||
- [ ] System prompt body present and non-empty
|
||||
- [ ] Body does not exceed 30,000 characters
|
||||
- [ ] No `<!-- -->` template comments remain in frontmatter
|
||||
|
||||
At plugin/APM scope, apply a **minor bump** to the resolved package's `apm.yml` `version` (single manifest, e.g. `1.0.4` → `1.1.0`).
|
||||
|
||||
Invoke `kyberforge:agent-audit` on the created file(s) before closing — validates the pair at project/user scope, the single file at plugin/APM scope.
|
||||
|
||||
**Commit verification.** Capture `git log --oneline -1` before Step 1 and keep it. Once the audit is clean, run `git add` and `git commit` for the new agent files — do not stop at staging. Then run `git log --oneline -1` again and confirm the hash changed from the one you captured at the start. A non-empty `git diff --stat` is not sufficient proof of completion: staged-but-uncommitted work isn't part of any commit and can be silently lost if the working tree is cleaned up before a commit lands. Only report the agent as done once the hash has actually changed.
|
||||
**Commit verification.** Once the audit is clean, run `git add` and `git commit` for the new agent files — do not stop at staging. Then confirm `git log --oneline -1` differs from the hash captured before Step 1. A non-empty `git diff --stat` is not proof of completion: staged-but-uncommitted work is part of no commit and can be silently lost if the working tree is cleaned up. Only report the agent as done once the hash has actually changed.
|
||||
|
||||
## Improving an existing agent
|
||||
|
||||
@@ -223,9 +221,7 @@ Confirm the agent files exist and at least one improvement signal is present in
|
||||
|
||||
If no signals: "This skill applies existing signals to an agent. For a blind review, examine the files manually or run a grill session first."
|
||||
|
||||
Verify `kyberforge:agent-audit` is available — it ships with the kyberforge plugin and is co-installed with this skill. If unavailable, stop and tell the user to install the kyberforge plugin before continuing.
|
||||
|
||||
Capture `git log --oneline -1` now, before making any edits — Step 5 needs it to verify a real commit landed.
|
||||
Verify `kyberforge:agent-audit` is available, as in the create flow's Prerequisites. Capture `git log --oneline -1` now, before making any edits — Step 5 needs it to verify a real commit landed.
|
||||
|
||||
**Partial state (project/user scope only)** — if one provider file exists but not the other, scaffold the missing one (`bash scripts/new-agent.sh <name> <root>`, file-by-file no-op) then continue. Doesn't apply at plugin/APM scope — single file, no partial-pair state.
|
||||
|
||||
@@ -248,7 +244,7 @@ Before editing, state which root causes were identified, what evidence supports
|
||||
|
||||
### Step 4 — Apply changes
|
||||
|
||||
Edit any file the signals point to. Generalize the fix — find the underlying gap, not the specific example that failed. For every sentence you add, ask: "Would the agent get this wrong without it?" A shorter, focused definition consistently outperforms an exhaustive one. For Copilot files, verify no Claude Code-only fields are introduced. For a plugin/APM-scope single file, verify no field beyond `name`, `description`, `model`, and `source_keys` is introduced.
|
||||
Edit any file the signals point to. Generalize the fix — find the underlying gap, not the specific example that failed. For every sentence you add, ask: "Would the agent get this wrong without it?" A shorter, focused definition consistently outperforms an exhaustive one. For Copilot files, verify no Claude Code-only fields are introduced. For a plugin/APM-scope single file, verify every field is still in the `apm-agent-allowlist` section of `agent-audit`'s `references/field-inventory.md`, and that an existing `disallowedTools` fence was not dropped by the edit.
|
||||
|
||||
If the edit adds or removes research-sourced content, update `source_keys` in the edited file(s) and the corresponding entry in `sources.md` per Create flow's Step 4.
|
||||
|
||||
@@ -260,4 +256,4 @@ At plugin/APM scope, apply a **patch bump** to the resolved package's `apm.yml`
|
||||
|
||||
Invoke `kyberforge:agent-audit` on the edited file(s) to confirm no regressions — the pair at project/user scope, the single file at plugin/APM scope.
|
||||
|
||||
**Commit verification.** Capture `git log --oneline -1` at the start of Step 1 and keep it. Once the audit is clean, run `git add` and `git commit` for the changed files — do not stop at staging. Then run `git log --oneline -1` again and confirm the hash changed from the one you captured at the start. A non-empty `git diff --stat` is not sufficient proof of completion: staged-but-uncommitted work isn't part of any commit and can be silently lost if the working tree is cleaned up before a commit lands. Only report the improvement as done once the hash has actually changed.
|
||||
**Commit verification.** Exactly as in the create flow's Step 5, against the hash captured at Step 1: commit the changed files once the audit is clean, and only report the improvement as done once `git log --oneline -1` shows a different hash.
|
||||
@@ -6,4 +6,4 @@ Annotated agent definition templates copied by `scripts/new-agent.sh` when scaff
|
||||
|
||||
- **`claude-code.md`** — Claude Code agent definition template (project/user scope). Includes all supported frontmatter fields (required and optional) with inline guidance comments and `FILL IN:` placeholders.
|
||||
- **`copilot.agent.md.template`** — Copilot CLI agent definition template (CLI format, project/user scope). Excludes cloud/IDE-only fields (`target`, `user-invocable`, `disable-model-invocation`, `mcp-servers`) and Claude Code-only fields. Uses Copilot tool aliases (`execute`, `read`, `edit`, `search`, `agent`, `web`).
|
||||
- **`apm-agent.md`** — Vendor-neutral APM agent definition template (plugin/APM scope). Only `name`, `description`, optional `model`, and optional `source_keys` (provenance metadata, not a runtime field) in frontmatter — no `tools` and no Claude-only fields, since `apm compile` copies frontmatter verbatim to both the Claude Code and Copilot CLI targets with no per-target integrator (ADR-0016).
|
||||
- **`apm-agent.md`** — Vendor-neutral APM agent definition template (plugin/APM scope). Frontmatter is limited to the `apm-agent-allowlist` section of `agent-audit`'s `references/field-inventory.md` — the authoritative list, read from there as data by `agent-audit`'s `validate.sh`; this file deliberately does not restate it. No `tools` and no Claude-only knobs, since `apm compile` copies frontmatter verbatim to both the Claude Code and Copilot CLI targets with no per-target integrator; `disallowedTools` is scaffolded as an opt-in comment because a denylist, unlike the `tools` allowlist, survives that copy (ADR-0016 and its 2026-08-14 amendment).
|
||||
@@ -2,18 +2,25 @@
|
||||
<!-- Vendor-neutral APM agent definition (plugin/APM scope).
|
||||
Path: <package-root>/.apm/agents/<name>.agent.md — one file, no counterpart.
|
||||
`apm compile` copies this frontmatter verbatim to BOTH the Claude Code and
|
||||
Copilot CLI targets — there is no per-target field integrator. Claude's
|
||||
`tools:` (space-separated string) and Copilot's `tools:` (alias list) are
|
||||
incompatible vocabularies, and Claude-only fields (isolation, maxTurns,
|
||||
effort, memory, permissionMode) have no Copilot equivalent. A value correct
|
||||
for one harness is guaranteed wrong on the other, so this scope carries
|
||||
ONLY the fields below — full stop (see ADR-0016). `source_keys` is
|
||||
provenance metadata, not a runtime field, and is exempt from that rule.
|
||||
Copilot CLI targets, with no per-target field integrator to reconcile
|
||||
anything, so a harness-specific value is wrong on at least one of them.
|
||||
|
||||
Do NOT add: tools, isolation, maxTurns, effort, memory, permissionMode,
|
||||
disallowedTools, skills, color, initialPrompt, background, hooks, or
|
||||
mcpServers. Omitting `tools` means inherit-all-tools on both harnesses,
|
||||
which is never wrong.
|
||||
This template does not restate the permitted-field list. The authoritative
|
||||
list is the `apm-agent-allowlist` section of agent-audit's
|
||||
references/field-inventory.md, which agent-audit's validate.sh reads from
|
||||
there as data — a list copied into a template goes stale one step further
|
||||
out than the list itself. Every field scaffolded below is on it; before
|
||||
adding any other field, check that section.
|
||||
|
||||
The shape rule behind the list (ADR-0016 and its 2026-08-14 amendment):
|
||||
`tools` is an ALLOWLIST whose vocabulary differs per harness — Claude tool
|
||||
names vs Copilot's execute/read/edit/search/agent/web — so one value is
|
||||
wrong on one target. Never add it here; omitting it means inherit-all-tools
|
||||
on both harnesses, which is never wrong. `disallowedTools` is a DENYLIST
|
||||
and is allowed for exactly that reason: a name the other harness does not
|
||||
recognise denies nothing, so the worst case is a missing fence, never a
|
||||
wrongly granted capability. Claude-only knobs (isolation, maxTurns, effort,
|
||||
memory, permissionMode) have no Copilot equivalent and stay out.
|
||||
|
||||
Fill in all FILL IN: placeholders. Delete template comments before shipping. -->
|
||||
|
||||
@@ -30,6 +37,16 @@ description: FILL IN: Action-first description of what this agent does and when
|
||||
Optional. Aliases: sonnet, opus, haiku, fable. Or full model ID.
|
||||
Omit to inherit the runtime default on whichever harness compiles this file. -->
|
||||
|
||||
<!-- disallowedTools: Edit, Write, NotebookEdit
|
||||
Optional. Denylist, applied before `tools` and taking precedence over it.
|
||||
Add it when this agent is read-only — it is the one tool restriction that
|
||||
survives verbatim copy (see the header comment). Claude Code honours it for
|
||||
plugin subagents — confirmed. Copilot's handling of the key is unconfirmed;
|
||||
ADR-0016 accepts that as a stated risk rather than a settled fact.
|
||||
It denies only the tools it names. It does NOT deny Bash, which this agent
|
||||
inherits, so a shell redirect still writes — state the read-only boundary
|
||||
in the system prompt body as well, not in frontmatter alone. -->
|
||||
|
||||
<!-- source_keys:
|
||||
- slug-name
|
||||
Development-only. Add when research sources informed this agent (slugs must match
|
||||
|
||||
@@ -14,8 +14,9 @@ description: FILL IN: Action-first description of what this agent does and when
|
||||
Be specific about the triggering condition and domain.
|
||||
Example: "Reviews pull request diffs for security issues. Use proactively after code changes." -->
|
||||
|
||||
<!-- tools: Read Bash Grep
|
||||
Optional. Space-separated allowlist. Omit to inherit all tools from parent.
|
||||
<!-- tools: Read, Bash, Grep
|
||||
Optional. Allowlist of tool names: a comma-separated string or a YAML list.
|
||||
Omit to inherit all tools from parent.
|
||||
Use Agent(type1,type2) to restrict which subagent types this agent can spawn.
|
||||
Omit Agent entirely to prevent this agent from spawning subagents.
|
||||
Never available to subagents regardless of tools field:
|
||||
@@ -47,9 +48,13 @@ description: FILL IN: Action-first description of what this agent does and when
|
||||
<!-- background: false
|
||||
Optional. Set true to force background execution. -->
|
||||
|
||||
<!-- disallowedTools: mcp__filesystem__write_file
|
||||
Optional. Space-separated denylist, applied before the tools allowlist.
|
||||
Supports mcp__* glob patterns (e.g. mcp__filesystem__* to block all filesystem tools). -->
|
||||
<!-- disallowedTools: Edit, Write, NotebookEdit
|
||||
Optional. Denylist, applied before the tools allowlist and taking precedence over it.
|
||||
Accepts a YAML list or a delimited string; use the comma-separated string form for
|
||||
consistency with the plugin-scope agents in this repo.
|
||||
Supports mcp__* glob patterns (e.g. mcp__filesystem__* to block all filesystem tools).
|
||||
Denies only the tools it names — it does not deny Bash, so an agent that inherits
|
||||
Bash can still write via a shell redirect. State read-only intent in the body too. -->
|
||||
|
||||
<!-- skills:
|
||||
- skill-name
|
||||
|
||||
@@ -6,7 +6,7 @@ source_keys: []
|
||||
|
||||
## deployment-modes.md
|
||||
|
||||
Agent scope hierarchy, precedence rules, and per-scope restrictions. Covers: which fields are silently ignored for plugin agents (Claude Code and Copilot CLI), scoped identifiers for plugin subdirectory agents, cache isolation behaviour, and Copilot CLI path conventions. Loaded conditionally from SKILL.md when the destination is a plugin directory.
|
||||
Agent scope hierarchy, precedence rules, and per-scope restrictions. Covers: which frontmatter fields a plugin/APM-scope agent may carry and the allowlist-vs-denylist shape rule that decides it (deferring to `agent-audit`'s `references/field-inventory.md` for the list itself), scoped identifiers for plugin subdirectory agents, cache isolation behaviour, and Copilot CLI path conventions. Loaded conditionally from SKILL.md when the destination is a plugin directory.
|
||||
|
||||
## scripts.md
|
||||
|
||||
|
||||
@@ -24,9 +24,11 @@ When the same agent `name` appears at multiple scopes, **user scope wins over pr
|
||||
|
||||
## Plugin scope restrictions
|
||||
|
||||
Plugin/APM agents (`.apm/agents/<name>.agent.md`) carry only `name`, `description`, optionally `model`, and optionally `source_keys` (provenance metadata, not a runtime field — silently ignored by both harnesses) in frontmatter — full stop (see ADR-0016). `apm compile` copies this frontmatter verbatim to both the Claude Code and Copilot CLI compile targets with no per-target integrator: Claude's `tools:` (space-separated string) and Copilot's `tools:` (alias list) are incompatible vocabularies, and Claude-only fields have no Copilot equivalent, so any harness-specific value is guaranteed wrong on at least one target.
|
||||
Plugin/APM agents (`.apm/agents/<name>.agent.md`) carry only the fields in the `apm-agent-allowlist` section of `agent-audit`'s `references/field-inventory.md`. That section is the authoritative list — `agent-audit`'s `validate.sh` reads it from there as data, and it changes — so consult it rather than any restatement of it. `apm compile` copies this frontmatter verbatim to both the Claude Code and Copilot CLI compile targets with no per-target integrator, so a harness-specific value is guaranteed wrong on at least one target (ADR-0016).
|
||||
|
||||
This makes the old "silently ignored at plugin scope" framing moot. It's not that `hooks`, `mcpServers`, `permissionMode`, `tools`, `isolation`, `maxTurns`, `effort`, `memory`, `disallowedTools`, `skills`, `color`, `initialPrompt`, or `background` are merely ignored at this scope — they are never written to the file at all. Copy the agent to `.claude/agents/` (project scope) or `~/.claude/agents/` (user scope) to use any of them.
|
||||
**The rule is about a field's shape, not a fixed roster.** `tools` is an **allowlist** whose vocabulary differs per harness — Claude Code names its own tools, Copilot CLI uses aliases (`execute`/`read`/`edit`/`search`/`agent`/`web`) — so under verbatim copy one value is wrong on one target. It stays out. `disallowedTools` is a **denylist**, and denying by name has no such conflict: a name the other harness does not recognise denies nothing, so the worst case is that the fence is absent there, never that a capability is wrongly granted. That asymmetry is why the denylist is admitted where the allowlist is not (ADR-0016's 2026-08-14 amendment). Claude Code honours it for plugin subagents — `docs/research/docs/claude-code-plugins/agent-definition.md:99` names the three fields plugin agents silently ignore (`hooks`, `mcpServers`, `permissionMode`) and `disallowedTools` is not among them. It is a partial fence: it denies only the tools it names, not `Bash`, which a plugin-scope agent with no `tools` inherits — so state read-only intent in the body too.
|
||||
|
||||
This makes the old "silently ignored at plugin scope" framing moot for the excluded fields. It's not that `hooks`, `mcpServers`, `permissionMode`, `tools`, `isolation`, `maxTurns`, `effort`, `memory`, `skills`, `color`, `initialPrompt`, or `background` are merely ignored at this scope — they are never written to the file at all. Copy the agent to `.claude/agents/` (project scope) or `~/.claude/agents/` (user scope) to use any of them.
|
||||
|
||||
## Scoped identifiers (Claude Code plugin agents only)
|
||||
|
||||
|
||||
@@ -15,6 +15,7 @@ All scripts in this skill must follow these rules:
|
||||
- **Idempotent** — "create if not exists" per file. The scaffold script skips any file that already exists; agents may safely re-run it.
|
||||
- **Meaningful exit codes** — `0` success, `1` invalid arguments or precondition failure. Document in `--help`.
|
||||
- **Self-contained** — no external package installs at runtime. The script uses only bash builtins and POSIX tools (`sed`, `mkdir`, `cat`).
|
||||
- **No restated field rosters** — no script output, in `--help` or in next-steps guidance, enumerates permitted, forbidden, or required frontmatter fields. Point at the `apm-agent-allowlist` section of `agent-audit`'s `references/field-inventory.md`, which `agent-audit`'s `validate.sh` reads from there as data. A roster copied into script output goes stale one step further out than the list itself: the next-steps hint `(name, description, model, body only)` kept printing after ADR-0016's 2026-08-14 amendment added `disallowedTools` to the permitted set. `tests/new-agent.bats` enforces this for the plugin/APM branch — naming some allowlisted fields but not all is a failure.
|
||||
|
||||
## Template variables
|
||||
|
||||
|
||||
@@ -10,4 +10,4 @@ Usage: new-agent.sh <agent-name> <root>
|
||||
|
||||
Resolves scope by walking up from `<root>`: a `type:`-bearing `apm.yml` found at or above `<root>` → plugin/APM scope (single file at `<package-root>/.apm/agents/<name>.agent.md`; an `apm.yml` without `type:` is a marketplace-only manifest and is skipped); `<root>` exactly `~` → user scope (`~/.claude/agents/` + `~/.copilot/agents/`); otherwise project scope (`<root>/.claude/agents/` + `<root>/.github/agents/`). Each file is a no-op if it already exists. See `--help` for full usage.
|
||||
|
||||
Tests: `tests/new-agent.bats` (requires `bats-support` and `bats-assert`).
|
||||
Tests: `tests/new-agent.bats` (requires `bats-support` and `bats-assert`) — source-only. `scripts/sync-plugin-content.sh` strips `<category>/<name>/tests` from the generated mirror (ADR-0017), so this file exists in a repo checkout of `.apm/skills/agent-author/` and not in an installed plugin.
|
||||
@@ -21,10 +21,12 @@ Arguments:
|
||||
without type: is a marketplace-only manifest
|
||||
and is skipped, the walk continues upward
|
||||
→ creates <package-root>/.apm/agents/<name>.agent.md
|
||||
(single vendor-neutral file — no tools,
|
||||
isolation, maxTurns, effort, memory, or
|
||||
permissionMode; apm compile has no per-target
|
||||
field integrator, see ADR-0016)
|
||||
(single vendor-neutral file; apm compile copies
|
||||
its frontmatter verbatim to every target with no
|
||||
per-target field integrator, so the permitted
|
||||
field set is narrow — see the apm-agent-allowlist
|
||||
section of agent-audit's
|
||||
references/field-inventory.md and ADR-0016)
|
||||
→ creates <package-root>/sources.md (if absent)
|
||||
project scope : no type:-bearing apm.yml found; root is a
|
||||
project directory
|
||||
@@ -258,6 +260,19 @@ SOURCES
|
||||
fi
|
||||
fi
|
||||
|
||||
# Next-steps guidance names no frontmatter fields, by rule (see references/scripts.md).
|
||||
# A roster restated in terminal output goes stale one step further out than the list
|
||||
# itself: the old "(name, description, model, body only)" hint outlived ADR-0016's
|
||||
# 2026-08-14 amendment, which added disallowedTools to the permitted set. Point at the
|
||||
# scaffolded file's own comments for what to fill, and at agent-audit's validate.sh —
|
||||
# which reads the allowlist from field-inventory.md as data — for what is permitted.
|
||||
AUDIT_SCRIPTS="$(cd "$SKILL_ROOT/../agent-audit/scripts" 2>/dev/null && pwd || true)"
|
||||
if [[ -n "$AUDIT_SCRIPTS" && -f "$AUDIT_SCRIPTS/validate.sh" ]]; then
|
||||
VALIDATE_HINT="$AUDIT_SCRIPTS/validate.sh"
|
||||
else
|
||||
VALIDATE_HINT="agent-audit's scripts/validate.sh"
|
||||
fi
|
||||
|
||||
if [[ "$created_any" == false ]]; then
|
||||
echo "All files already exist — nothing to do." >&2
|
||||
else
|
||||
@@ -266,12 +281,17 @@ else
|
||||
echo "" >&2
|
||||
echo "Next steps:" >&2
|
||||
if [[ "$SCOPE" == "plugin" ]]; then
|
||||
echo " 1. Fill in $APM_FILE — replace all FILL IN: placeholders (name, description, model, body only)" >&2
|
||||
echo " 1. Fill in $APM_FILE — replace every FILL IN: placeholder. Optional fields are" >&2
|
||||
echo " scaffolded there as commented blocks; uncomment the ones that apply." >&2
|
||||
echo " 2. Populate $SOURCES_DIR/sources.md with research sources, or delete it" >&2
|
||||
echo " 3. Validate: check required fields (name, description, system prompt) in the file" >&2
|
||||
echo " 3. Validate: $VALIDATE_HINT $APM_FILE" >&2
|
||||
echo " It checks the frontmatter against the apm-agent-allowlist section of" >&2
|
||||
echo " agent-audit's references/field-inventory.md, the authoritative field list." >&2
|
||||
else
|
||||
echo " 1. Fill in $CC_FILE — replace all FILL IN: placeholders" >&2
|
||||
echo " 2. Fill in $CP_FILE — replace all FILL IN: placeholders" >&2
|
||||
echo " 3. Validate: check required fields (name, description, system prompt) in both files" >&2
|
||||
echo " 1. Fill in $CC_FILE — replace every FILL IN: placeholder. Optional fields are" >&2
|
||||
echo " scaffolded there as commented blocks; uncomment the ones that apply." >&2
|
||||
echo " 2. Fill in $CP_FILE — same, and heed its closing comment: the Claude Code-only" >&2
|
||||
echo " fields it names must not cross over from the file above." >&2
|
||||
echo " 3. Validate: run $VALIDATE_HINT on each file" >&2
|
||||
fi
|
||||
fi
|
||||
@@ -77,19 +77,67 @@ teardown() {
|
||||
assert_failure
|
||||
}
|
||||
|
||||
@test "plugin/APM scope: frontmatter carries only name, description, model, source_keys fields" {
|
||||
# The permitted set is read from the same data agent-audit's validate.sh reads --
|
||||
# the apm-agent-allowlist section of agent-audit's field-inventory.md -- rather than
|
||||
# restated here. A hardcoded copy drifts: this assertion listed four fields and went
|
||||
# on passing after ADR-0016's amendment added disallowedTools, and would have
|
||||
# rejected a scaffolded agent that legitimately carried it.
|
||||
@test "plugin/APM scope: frontmatter carries only allowlisted fields" {
|
||||
inventory="$BATS_TEST_DIRNAME/../../agent-audit/references/field-inventory.md"
|
||||
[ -f "$inventory" ] || fail "field-inventory.md not found at $inventory"
|
||||
allowlist="$(awk '
|
||||
/^## apm-agent-allowlist$/ { insection = 1; next }
|
||||
insection && /^##/ { exit }
|
||||
insection && NF && $0 !~ /^#/ && $0 !~ /^---/ { print; exit }
|
||||
' "$inventory")"
|
||||
[ -n "$allowlist" ] || fail "apm-agent-allowlist section is empty in $inventory"
|
||||
|
||||
printf 'name: my-package\ntype: skill\n' > "$ROOT/apm.yml"
|
||||
bash "$SCRIPT" my-agent "$ROOT"
|
||||
file="$ROOT/.apm/agents/my-agent.agent.md"
|
||||
fm="$(sed -n '/^---$/,/^---$/p' "$file")"
|
||||
keys="$(grep -oE '^[a-zA-Z][a-zA-Z0-9_-]*:' <<< "$fm" | sed 's/:$//' | sort -u)"
|
||||
for key in $keys; do
|
||||
if [[ "$key" != "name" && "$key" != "description" && "$key" != "model" && "$key" != "source_keys" ]]; then
|
||||
fail "unexpected frontmatter key: $key"
|
||||
if ! grep -qw "$key" <<< "$allowlist"; then
|
||||
fail "frontmatter key '$key' is not in field-inventory.md's apm-agent-allowlist ($allowlist)"
|
||||
fi
|
||||
done
|
||||
}
|
||||
|
||||
# Same drift class as the assertion above, one step further out: the next-steps text
|
||||
# used to enumerate "(name, description, model, body only)" and went on printing that
|
||||
# after ADR-0016's amendment added disallowedTools. Rather than assert on wording, this
|
||||
# asserts the roster is all-or-nothing: if the guidance names any allowlisted field it
|
||||
# must name every one of them, so a partial restatement -- the only shape that can go
|
||||
# stale silently -- fails. Naming none, the current design, passes.
|
||||
@test "plugin/APM scope: next-steps guidance does not partially restate the allowlist" {
|
||||
inventory="$BATS_TEST_DIRNAME/../../agent-audit/references/field-inventory.md"
|
||||
[ -f "$inventory" ] || fail "field-inventory.md not found at $inventory"
|
||||
allowlist="$(awk '
|
||||
/^## apm-agent-allowlist$/ { insection = 1; next }
|
||||
insection && /^##/ { exit }
|
||||
insection && NF && $0 !~ /^#/ && $0 !~ /^---/ { print; exit }
|
||||
' "$inventory")"
|
||||
[ -n "$allowlist" ] || fail "apm-agent-allowlist section is empty in $inventory"
|
||||
|
||||
printf 'name: my-package\ntype: skill\n' > "$ROOT/apm.yml"
|
||||
steps="$(bash "$SCRIPT" my-agent "$ROOT" 2>&1 | sed -n '/^Next steps:/,$p')"
|
||||
[ -n "$steps" ] || fail "scaffolder printed no next-steps block"
|
||||
|
||||
named=""
|
||||
missing=""
|
||||
for field in $allowlist; do
|
||||
if grep -qw -- "$field" <<< "$steps"; then
|
||||
named="$named $field"
|
||||
else
|
||||
missing="$missing $field"
|
||||
fi
|
||||
done
|
||||
if [ -n "$named" ] && [ -n "$missing" ]; then
|
||||
fail "next-steps names allowlisted field(s)$named but omits$missing -- a partial roster. Point at field-inventory.md instead of restating it."
|
||||
fi
|
||||
}
|
||||
|
||||
@test "plugin/APM scope: sources.md contributing-files template mentions the single-file path" {
|
||||
printf 'name: my-package\ntype: skill\n' > "$ROOT/apm.yml"
|
||||
bash "$SCRIPT" my-agent "$ROOT"
|
||||
|
||||
@@ -33,6 +33,11 @@ Provide the path to the skill directory to audit when invoking.
|
||||
| `references/description-quality.md` | Spec-grounded rubric for description auditing — loaded when a finding is borderline |
|
||||
| `references/body-discipline.md` | Spec-grounded rubric for body discipline auditing — loaded when padding vs necessity is unclear |
|
||||
| `references/sources.md` | Provenance record — agentskills.io sources that informed this skill and which files each contributed to |
|
||||
| `tests/validate.bats` | Bats test suite for validate.sh |
|
||||
| `tests/validate-provenance.bats` | Bats test suite for validate-provenance.sh |
|
||||
| `tests/README.md` | Setup instructions for bats-support and bats-assert test dependencies |
|
||||
| `tests/validate.bats` | (source-only) Bats test suite for validate.sh |
|
||||
| `tests/validate-provenance.bats` | (source-only) Bats test suite for validate-provenance.sh |
|
||||
| `tests/README.md` | (source-only) Setup instructions for bats-support and bats-assert test dependencies |
|
||||
|
||||
Rows marked **(source-only)** exist in the authoring source (`.apm/skills/skill-audit/`) but are
|
||||
not present in an installed plugin: `scripts/sync-plugin-content.sh` strips
|
||||
`<category>/<name>/tests` when it generates the flat mirror, because these are dev-time fixtures no
|
||||
plugin host needs to discover (ADR-0017). Run them from a repo checkout, not from an install.
|
||||
@@ -46,8 +46,16 @@ If the destination resolves inside an APM package, read `references/deployment-m
|
||||
| `assets/templates/references/sources.md` | Sources provenance template for new skills |
|
||||
| `assets/templates/assets/README.md` | Placeholder for static assets |
|
||||
| `assets/templates/tests/README.md` | Placeholder for test files |
|
||||
| `tests/new-skill.bats` | Bats test suite for `scripts/new-skill.sh` |
|
||||
| `tests/README.md` | Setup instructions for bats-support and bats-assert test dependencies |
|
||||
| `tests/new-skill.bats` | (source-only) Bats test suite for `scripts/new-skill.sh` |
|
||||
| `tests/README.md` | (source-only) Setup instructions for bats-support and bats-assert test dependencies |
|
||||
|
||||
Rows marked **(source-only)** exist in the authoring source (`.apm/skills/skill-author/`) but are
|
||||
not present in an installed plugin: `scripts/sync-plugin-content.sh` strips
|
||||
`<category>/<name>/tests` when it generates the flat mirror, because these are dev-time fixtures no
|
||||
plugin host needs to discover (ADR-0017). Run them from a repo checkout, not from an install. The
|
||||
`assets/templates/tests/README.md` row above is **not** source-only — the exclusion is depth-scoped
|
||||
to `<category>/<name>/tests`, so the scaffolding template tree ships intact, which
|
||||
`scripts/new-skill.sh` depends on at runtime.
|
||||
|
||||
## Spec reference
|
||||
|
||||
|
||||
@@ -5,9 +5,11 @@ description: Orchestrates apm package/marketplace operations for other agents. I
|
||||
|
||||
source_keys:
|
||||
- context7-microsoft-apm
|
||||
|
||||
disallowedTools: Edit, Write, NotebookEdit
|
||||
---
|
||||
|
||||
You are the orchestrator for apm package/marketplace operations — a composable workflow dispatcher designed for other agents to invoke multi-step `apm` operations reliably, especially the same operation repeated across several packages in a monorepo-hybrid layout. Your one job is routing and safety-gating: you do not decide manifest content yourself, you delegate to `apm-workflow` and enforce confirmation on irreversible operations.
|
||||
You are the orchestrator for apm package/marketplace operations — a composable workflow dispatcher designed for other agents to invoke multi-step `apm` operations reliably, especially the same operation repeated across several packages in a monorepo-hybrid layout. Your one job is routing and safety-gating: you do not decide manifest content yourself, you delegate to `apm-workflow` and enforce confirmation on irreversible operations. You never edit files. Every manifest or primitive that changes under your dispatch is written by `apm-workflow` or by `apm` itself — never by an edit you make.
|
||||
|
||||
You resolve the package root once per dispatched operation (the directory containing that package's `apm.yml`) and carry it forward as session context rather than making every call re-resolve it.
|
||||
|
||||
@@ -21,6 +23,7 @@ These are non-negotiable regardless of `confirm` or any skill-local override:
|
||||
- `apm.yml`'s `type:` field constrains what `.apm/` may contain — when scaffolding (`init-package`), set `type:` before any primitive content is added; do not defer it.
|
||||
- A clean plain `apm audit` is not a CI-equivalent pass — if the caller's intent is a CI gate, dispatch `audit-ci`, not `audit`.
|
||||
- Check the `apm experimental enable registries` precondition before dispatching any operation that depends on a named registry, and fail with a clear diagnostic rather than silently no-op'ing like apm itself does — see apm-workflow/SKILL.md Gotchas for the underlying constraint.
|
||||
- You are read-only against the working tree. Never create, edit, or delete a file — not an `apm.yml`, not a `.apm/` primitive, not compiled output, not a scratch note. `edit-config` is an operation you *route* to `apm-workflow`, never one you perform: dispatching it is allowed only when the caller asked for that edit, never as your own repair of something you noticed.
|
||||
|
||||
When invoked, you:
|
||||
1. Parse the incoming workflow request (operation type, parameters, target package(s), context overrides)
|
||||
@@ -52,7 +55,7 @@ When invoked, you:
|
||||
4. Verify `apm --version` succeeds; if not, fail with a diagnostic pointing to `apm-install`
|
||||
5. Invoke `apm-workflow` via `Skill` with the resolved action, `package_root`, and parameters
|
||||
6. If fanning across multiple packages, dispatch independent packages in parallel when no shared state or ordering dependency exists between them; loop package-by-package (strictly sequential) only for packages with a real dependency on another package's completion. Either way, collect per-package results and failures rather than aborting on the first failure
|
||||
7. Catch and handle apm errors: retry once for a dependency-not-yet-scaffolded failure after the caller confirms the dependency exists; otherwise return error structure with diagnostics
|
||||
7. Catch and handle apm errors: retry once for a dependency-not-yet-scaffolded failure after the caller confirms the dependency exists; otherwise return error structure with diagnostics. If the failure looks trivially fixable by a one-line manifest edit — a missing `category:`, a typo'd `source:`, a version that disagrees between a package and the catalog — name that fix in `suggestions` and stop. Do not apply it yourself and do not self-dispatch an `edit-config` to apply it
|
||||
8. Aggregate all outputs and return as structured JSON
|
||||
|
||||
## Output
|
||||
|
||||
@@ -2,7 +2,11 @@
|
||||
|
||||
Plugin documentation. Not read automatically by Claude Code or GitHub Copilot CLI — reference specific files from skill bodies or agent prompts as needed.
|
||||
|
||||
This directory currently holds no standalone documents of its own — everything under it is research material.
|
||||
| Path | Purpose |
|
||||
|------|---------|
|
||||
| `hooks.md` | Where this plugin's hooks are authored (`.apm/hooks/`), where they are generated to (`hooks/hooks.json`), and the Claude Code and Copilot CLI schemas |
|
||||
|
||||
Everything else under this directory is research material.
|
||||
|
||||
## research/
|
||||
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
# Hooks
|
||||
|
||||
Reference for this plugin's hook definitions: where to edit them, where they end up, and what the
|
||||
host reads.
|
||||
|
||||
This document lives in `docs/` rather than next to the hooks it describes. `plugins/kyberforge/hooks/`
|
||||
is a **generated mirror** — `scripts/sync-plugin-content.sh` runs `rm -rf` on it before every
|
||||
rebuild, so any hand-written file placed there is deleted on the next sync with no drift warning
|
||||
(a prior copy of this document was lost exactly that way). See ADR-0017.
|
||||
|
||||
## Where to edit
|
||||
|
||||
Author hooks in `plugins/kyberforge/.apm/hooks/*.json`. `apm pack --format plugin` merges every
|
||||
file in that directory into a single `hooks.json`, which `sync-plugin-content.sh` copies to
|
||||
`plugins/kyberforge/hooks/hooks.json` — the path Claude Code convention-scans. Never edit the
|
||||
mirrored file; the `check-plugin-content-sync` pre-push hook reports it as drift.
|
||||
|
||||
## Claude Code structure
|
||||
|
||||
`hooks/hooks.json` is read by Claude Code. Structure:
|
||||
|
||||
```json
|
||||
{
|
||||
"hooks": {
|
||||
"PostToolUse": [
|
||||
{
|
||||
"matcher": "Bash",
|
||||
"hooks": [
|
||||
{ "type": "command", "command": "echo 'tool used'" }
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Events (**partial list**): `PreToolUse`, `PostToolUse`, `Notification`, `Stop`. Claude Code's plugin
|
||||
hook set is larger — `SessionStart`, `SessionEnd`, `UserPromptSubmit`, `PreCompact` and
|
||||
`SubagentStop` also exist — and this repo's vendored corpus does not enumerate it anywhere:
|
||||
`docs/research/docs/claude-code-plugins/configuration.md:100` describes the file as "Event handlers
|
||||
(PreToolUse, PostToolUse, etc.)", and `agent-definition.md:53` covers only the per-agent `hooks`
|
||||
field, not the plugin-level set. Treat the four names above as the ones this repo has verified, not
|
||||
as the schema. Check Claude Code's own hooks documentation before wiring an event not listed here.
|
||||
|
||||
Use `${CLAUDE_PLUGIN_ROOT}` to reference scripts inside this plugin — the plugin runs from a cache
|
||||
path after install, not its original repo location.
|
||||
|
||||
## GitHub Copilot CLI
|
||||
|
||||
Copilot reads a differently-shaped `hooks.json`: `version: 1` is required, each entry is
|
||||
`type: "command"` with separate `bash` and `powershell` scripts, and the lifecycle points are
|
||||
lowercase and differently named (`sessionStart`, `sessionEnd`, `userPromptSubmitted`, `preToolUse`,
|
||||
`postToolUse`, `errorOccurred`, `agentStop`). See
|
||||
`docs/research/docs/github-copilot-plugins/configuration.md`.
|
||||
|
||||
There is no separate Copilot hooks file at this plugin root, and — as things stand — **Copilot
|
||||
resolves to no hooks file at all.** Two corrections to an earlier revision of this document, which
|
||||
got both halves of this wrong:
|
||||
|
||||
**The deleted root `hooks.json` was not a stale sync artifact.** `plugins/kyberforge/hooks.json` was
|
||||
added in `2287ddc` (2026-06-20), the commit that created the plugin, well before
|
||||
`scripts/sync-plugin-content.sh` existed; `plugins/lint/hooks.json` arrived the same way in
|
||||
`f326df4`. Main's Copilot manifest `plugins/kyberforge/plugin.json` declared `"hooks": "hooks.json"`,
|
||||
and `plugins/lint/plugin.json` did the same — these were deliberately pointed-at Copilot hooks files,
|
||||
not leftovers. The sync (`38f1ba4`) later took ownership of that path, and ADR-0017's 2026-08-14
|
||||
amendment moved the generated file to `hooks/hooks.json` because that, not the plugin root, is the
|
||||
path Claude Code convention-scans.
|
||||
|
||||
**Only Claude Code resolves to `hooks/hooks.json`.** Claude Code finds it by auto-discovery.
|
||||
Copilot does not: `docs/research/docs/github-copilot-plugins/configuration.md:47` types `hooks` as a
|
||||
`plugin.json` field of type "string or object" with **no default**, so there is no convention path to
|
||||
scan, and `jq 'has("hooks")'` returns `false` for all six `.github/plugin/plugin.json` files that
|
||||
`apm pack` emits. With the pointer gone and no auto-discovery to fall back on, the Copilot ecosystem
|
||||
sees zero hooks.
|
||||
|
||||
The effect looks like the twin of the `mcpServers` gap that ADR-0017's 2026-08-13 amendment
|
||||
re-injects for: same "string or object" type, same absence of a default, same outcome of a Copilot
|
||||
manifest with no pointer. The *mechanism* differs, and ADR-0017 is explicit about it — `mcpServers`
|
||||
is actively stripped by `build_plugin_manifest`, whereas `hooks` "was never in
|
||||
`build_plugin_manifest`'s strip list at all"; it is simply never emitted, because `apm.yml` has no
|
||||
key that produces one. So this is an absence apm never fills, not a removal to reverse.
|
||||
|
||||
## Why no `hooks` pointer is injected
|
||||
|
||||
**Decided (2026-08-14, PR #95): the gap stays documented rather than patched.** `sync-plugin-content.sh`
|
||||
does *not* re-inject a `hooks` pointer into `.github/plugin/plugin.json`, and a test pins that
|
||||
absence. Full reasoning is in ADR-0017's "no `hooks` pointer" amendment; the short version, because
|
||||
the one-line fix looks obvious and someone will propose it again:
|
||||
|
||||
The `mcpServers` re-injection is safe because `.mcp.json` is **one format both ecosystems read**, so
|
||||
the pointer is a true statement about the file whatever it contains. Hooks have no shared format.
|
||||
Compare the two structures above: Claude Code wants `PreToolUse` with `matcher` objects; Copilot
|
||||
requires `version: 1`, lowercase event names, and per-shell `bash`/`powershell` keys. And apm merges
|
||||
`.apm/hooks/*.json` into **exactly one** `hooks.json` with no per-target shaping — the same file
|
||||
Claude Code convention-scans. One file, two incompatible readers.
|
||||
|
||||
So a pointer would tell Copilot that a Claude-shaped file is Copilot-shaped: an incomplete manifest
|
||||
traded for a wrong one. It is not inert even today — `{"hooks": {}}` has no `version: 1`, so the
|
||||
pointer would name a file invalid against the very schema it is pointed at from. And it does not
|
||||
become correct later: whoever writes the first real hook writes it in one shape, and it is the
|
||||
Claude shape in practice, since Claude Code auto-discovers the same file and is what hooks here are
|
||||
authored against.
|
||||
|
||||
**What this costs you:** a hook authored under `.apm/hooks/` reaches Claude Code and not Copilot.
|
||||
That is a real limitation, and it is the accepted one until apm emits a per-target hooks file or the
|
||||
two schemas converge. If you need a Copilot hook today, raise it — it needs an upstream change or a
|
||||
second authoring path, not a pointer.
|
||||
|
||||
## Symlinks under `.apm/` do not survive
|
||||
|
||||
Do not author any file under `plugins/kyberforge/.apm/` as a symlink. apm's bundle exporter filters
|
||||
symlinks out of the bundle entirely and says nothing, so the file never reaches the mirror. Since
|
||||
`sync-plugin-content.sh` builds both sides of its drift comparison from that same bundle, the loss
|
||||
used to be invisible to `--check` as well. `check_apm_symlinks()` now reads the `.apm/` source tree
|
||||
directly and fails the sync with the offending path — replace the symlink with a regular file.
|
||||
|
||||
It stays quiet about one place: `.apm/<category>/<name>/tests/`, the dev-fixture directory the
|
||||
mirror excludes anyway (a symlink there loses nothing, because nothing under it is mirrored). A
|
||||
`tests/` deeper than that — `assets/templates/tests/`, a scaffolding asset the mirror does carry —
|
||||
is reported like anywhere else. See ADR-0017's symlink amendment.
|
||||
@@ -7,11 +7,14 @@ plugin/APM scope, or a Claude Code and Copilot file pair at project/user scope.
|
||||
|
||||
At **plugin/APM scope**, accepts the single `.apm/agents/<name>.agent.md` file — there is no
|
||||
counterpart. Structural checks via `validate.sh` hard-`FAIL` any frontmatter field outside the
|
||||
vendor-neutral allowlist (`name`, `description`, `model`, `source_keys` — the last for
|
||||
provenance tracking, checked separately by `validate-provenance.sh` against `sources.md`; see
|
||||
ADR-0016), since `apm compile`
|
||||
copies frontmatter verbatim to both harnesses and an unsafe field can't be silently dropped for
|
||||
just one of them.
|
||||
vendor-neutral allowlist, since `apm compile` copies frontmatter verbatim to both harnesses and an
|
||||
unsafe field can't be silently dropped for just one of them. The allowlist itself lives in the
|
||||
`apm-agent-allowlist` section of `references/field-inventory.md` and is read from there as data —
|
||||
consult that section rather than any restatement of it, including this one. As of 2026-08-14 it
|
||||
admits `name`, `description`, `model`, `source_keys`, and `disallowedTools`; `source_keys` is
|
||||
provenance metadata checked separately by `validate-provenance.sh` against `sources.md`, and
|
||||
`disallowedTools` is admitted because a denylist survives verbatim copy where the `tools` allowlist
|
||||
does not (ADR-0016 and its 2026-08-14 amendment).
|
||||
|
||||
At **project/user scope**, accepts either file in a CC `.md` / Copilot `.agent.md` pair, derives
|
||||
the counterpart automatically, and validates both. Runs structural checks via `validate.sh`
|
||||
@@ -46,12 +49,17 @@ Pass the path to either agent file as the argument.
|
||||
| `assets/vale/styles/KyberforgeCopilot/ProactivePhrase.yml` | Flags CC-specific "Use proactively" phrasing with no effect in Copilot descriptions |
|
||||
| `references/README.md` | Directory documentation for references/ |
|
||||
| `references/description-quality.md` | Qualitative guide for borderline description findings |
|
||||
| `references/field-inventory.md` | Authoritative list of valid CC and Copilot agent fields |
|
||||
| `references/field-inventory.md` | Authoritative field lists read as data by `validate.sh`: valid CC and Copilot agent fields, and the vendor-neutral plugin/APM-scope allowlist |
|
||||
| `references/sources.md` | Research provenance for skill content |
|
||||
| `scripts/README.md` | Directory documentation for scripts/ |
|
||||
| `scripts/validate.sh` | Structural validation script for agent file pairs |
|
||||
| `scripts/validate-provenance.sh` | Provenance chain validation script for agent pairs against `sources.md` (plugin root) |
|
||||
| `scripts/vale-wrap.sh` | Drop-in `vale` wrapper that works around a frontmatter-description NLP scope limitation |
|
||||
| `tests/README.md` | Bats test dependency and run instructions |
|
||||
| `tests/validate.bats` | Bats tests for validate.sh |
|
||||
| `tests/validate-provenance.bats` | Bats tests for validate-provenance.sh |
|
||||
| `tests/README.md` | (source-only) Bats test dependency and run instructions |
|
||||
| `tests/validate.bats` | (source-only) Bats tests for validate.sh |
|
||||
| `tests/validate-provenance.bats` | (source-only) Bats tests for validate-provenance.sh |
|
||||
|
||||
Rows marked **(source-only)** exist in the authoring source (`.apm/skills/agent-audit/`) but are
|
||||
not present in an installed plugin: `scripts/sync-plugin-content.sh` strips
|
||||
`<category>/<name>/tests` when it generates the flat mirror, because these are dev-time fixtures no
|
||||
plugin host needs to discover (ADR-0017). Run them from a repo checkout, not from an install.
|
||||
@@ -44,13 +44,13 @@ The script accepts either the CC file, the Copilot file, or (at plugin/APM scope
|
||||
|
||||
At **project/user scope** it derives the counterpart and runs the existing pair-based checks. Note FAILs and SUGGESTIONs for the `### Structure` and `### Provider safety` report dimensions. Findings about missing fields, bad name format, empty body, or missing frontmatter → `### Structure`. Findings about CC-only fields in a Copilot file, Copilot-only fields in a CC file, body length, or subagent-unavailable tools → `### Provider safety`. A missing counterpart file → `### Pair consistency`.
|
||||
|
||||
At **plugin/APM scope** there is no counterpart — the script instead checks the single file's frontmatter against the `apm-agent-allowlist` in `references/field-inventory.md` (`name`, `description`, `model`, `source_keys` — nothing else; `source_keys` is provenance metadata, not a provider-specific field, and is validated separately by `validate-provenance.sh` against `sources.md`). Findings about missing fields, bad name format, name/filename-stem mismatch, empty body, or missing frontmatter → `### Structure`, same as project/user scope. Findings about any field outside the allowlist (e.g. `tools`, or any Claude-only/Copilot-only field carried over from a hand-edit) and body length → `### Provider safety` — but the dimension's meaning shifts here: it is no longer a CC-vs-Copilot field-leakage check, it's a vendor-neutral-field-allowlist check, since `apm compile` verbatim-copies this file's frontmatter to every target and there is no per-target integrator to reconcile a CC-only or Copilot-only field (ADR-0016). `### Pair consistency` never applies at this scope — the script never emits a missing-counterpart FAIL here, because there is nothing to pair by design.
|
||||
At **plugin/APM scope** there is no counterpart — the script instead checks the single file's frontmatter against the `apm-agent-allowlist` in `references/field-inventory.md`. Read that section for the current list rather than reciting one here; it is the authoritative source and it changes. As of 2026-08-14 it is `name`, `description`, `model`, `source_keys`, `disallowedTools` — `source_keys` is provenance metadata, not a provider-specific field, and is validated separately by `validate-provenance.sh` against `sources.md`; `disallowedTools` is a denylist, admitted because denying a tool by name is safe under `apm compile`'s verbatim copy in a way the `tools` allowlist is not (ADR-0016's 2026-08-14 amendment, and the rationale recorded alongside the list itself). Findings about missing fields, bad name format, name/filename-stem mismatch, empty body, or missing frontmatter → `### Structure`, same as project/user scope. Findings about any field outside the allowlist (e.g. `tools`, or any Claude-only/Copilot-only field carried over from a hand-edit) and body length → `### Provider safety` — but the dimension's meaning shifts here: it is no longer a CC-vs-Copilot field-leakage check, it's a vendor-neutral-field-allowlist check, since `apm compile` verbatim-copies this file's frontmatter to every target and there is no per-target integrator to reconcile a CC-only or Copilot-only field (ADR-0016). `### Pair consistency` never applies at this scope — the script never emits a missing-counterpart FAIL here, because there is nothing to pair by design.
|
||||
|
||||
`vale-wrap.sh` ships inside this skill's own `scripts/` — resolve it relative to this skill's directory the same way `scripts/validate.sh` is resolved above, so the invocation works whether this skill is running from this repo or from an installed plugin cache. Pass no `--config`: handed none, the wrapper loads its own sibling `assets/vale/.vale.ini`, located from the script's path rather than from the cwd. Adding an explicit relative `--config` breaks exactly the case the self-location covers — a resolved script path plus an unresolved config path yields `E100 Runtime error ... does not exist`, exit 2, which the fallback below then misreads as "vale unavailable". At project/user scope, run it against both files of the pair (not just the one passed in); at plugin/APM scope, run it against the single file. `Kyberforge` applies to all of these files via the `**/agents/*.md` glob; `KyberforgeCopilot` applies to any `*.agent.md` file — including the plugin/APM-scope file, which already has that extension — via the `**/*.agent.md` glob, since its one rule (`Use proactively`) flags CC-specific phrasing that's meaningless in a vendor-neutral or Copilot description. Every Vale alert is a `FAIL` — all rules are graded `error` — so report each one in the `### Description` / `### Body` dimensions citing its rule ID (e.g. `KyberforgeCopilot.ProactivePhrase`). Skip and fall back to Step 2 judgment if the `vale` binary is unavailable. If Vale reports `0 files` scanned, treat the pass as NOT RUN — not as clean — and fall back to full Step 2 judgment for the dimensions it would have covered.
|
||||
|
||||
`validate-provenance.sh` operates at plugin/APM scope only — it walks up from the agent file's directory the same way `validate.sh` does (nearest ancestor `apm.yml` with a top-level `type:` field; skip a `type:`-less marketplace-only `apm.yml`; stop at `.git` or the filesystem root) and exits 0 silently if that walk doesn't land on a package root, or when no provenance data exists. When it does apply, it validates the chain between the single file's own `source_keys` and the package-scoped `sources.md` (package root — see ADR-0010). Note FAILs from this script for the `### Provenance` dimension — surface them verbatim with Why and Fix.
|
||||
|
||||
If the scripts cannot run (Bash denied, python3 unavailable), perform checks manually. At project/user scope: counterpart file exists, required fields present (`name`, `description`, non-empty body), `name` is kebab-case, Copilot CLI `.agent.md` `name` must match filename stem (CC files are exempt — the CC platform does not require name to match filename), no `FILL IN:` placeholders, no CC-only fields in Copilot file, no Copilot-only fields in CC file (read `references/field-inventory.md` for the authoritative field lists). At plugin/APM scope: required fields present (`name`, `description`, non-empty body), `name` is kebab-case and matches the filename stem, no `FILL IN:` placeholders, no frontmatter field outside `name`/`description`/`model`/`source_keys` (read the `apm-agent-allowlist` section of `references/field-inventory.md`; `source_keys` carries provenance metadata, checked separately by `validate-provenance.sh` against `sources.md`).
|
||||
If the scripts cannot run (Bash denied, python3 unavailable), perform checks manually. At project/user scope: counterpart file exists, required fields present (`name`, `description`, non-empty body), `name` is kebab-case, Copilot CLI `.agent.md` `name` must match filename stem (CC files are exempt — the CC platform does not require name to match filename), no `FILL IN:` placeholders, no CC-only fields in Copilot file, no Copilot-only fields in CC file (read `references/field-inventory.md` for the authoritative field lists). At plugin/APM scope: required fields present (`name`, `description`, non-empty body), `name` is kebab-case and matches the filename stem, no `FILL IN:` placeholders, no frontmatter field outside the allowlist — read the `apm-agent-allowlist` section of `references/field-inventory.md` for it, do not work from memory (`source_keys` carries provenance metadata, checked separately by `validate-provenance.sh` against `sources.md`).
|
||||
|
||||
## Step 2 — Qualitative checks
|
||||
|
||||
|
||||
@@ -25,4 +25,25 @@ target disable-model-invocation user-invocable mcp-servers metadata
|
||||
|
||||
## apm-agent-allowlist
|
||||
|
||||
name description model source_keys
|
||||
name description model source_keys disallowedTools
|
||||
|
||||
Parsing note: `validate.sh` reads the **first** non-empty, non-`#`, non-`---` line under each
|
||||
heading as a whitespace-separated token list, and stops there. Keep the token line immediately
|
||||
below its heading; explanatory prose goes after it, as here.
|
||||
|
||||
Why `disallowedTools` is on a list that is otherwise vendor-neutral, when `tools` is not
|
||||
(ADR-0016 and its 2026-08-14 amendment): the two are not symmetric. `tools` is an **allowlist**
|
||||
whose vocabulary differs per harness — Claude Code names its own tools, Copilot CLI uses aliases
|
||||
(`execute`/`read`/`edit`/`search`/`agent`/`web`) — so a value correct for one is wrong for the
|
||||
other, and `apm compile` copies frontmatter verbatim with no per-target integrator to reconcile
|
||||
them. `disallowedTools` is a **denylist**, and denying by name is safe under verbatim copy: a name
|
||||
the other harness does not recognise denies nothing, so the worst case is that the fence is absent
|
||||
there, never that the wrong capability is granted. Claude Code honours it for plugin subagents —
|
||||
`docs/research/docs/claude-code-plugins/agent-definition.md:99` names the fields plugin agents
|
||||
silently ignore (`hooks`, `mcpServers`, `permissionMode`) and `disallowedTools` is not among them.
|
||||
|
||||
`disallowedTools` also appears in `claude-code-only-fields` above, and that stays correct: at
|
||||
project/user scope it is still a Claude-only field and must not appear in a Copilot `.agent.md`.
|
||||
The two lists answer different questions — "may this field cross the CC/Copilot file boundary" for
|
||||
a real pair, versus "is this field safe under verbatim copy to every target" for a single
|
||||
vendor-neutral APM file.
|
||||
@@ -8,9 +8,11 @@ Usage: validate.sh <agent-file>
|
||||
Validate an agent definition file against the agent definition spec.
|
||||
|
||||
At plugin/APM scope, <agent-file> is a single vendor-neutral
|
||||
.apm/agents/<name>.agent.md file (frontmatter allowlist: name, description,
|
||||
model — no counterpart file). At project or user scope, <agent-file> is
|
||||
either half of a Claude Code .md / Copilot .agent.md pair.
|
||||
.apm/agents/<name>.agent.md file with no counterpart. Its frontmatter allowlist
|
||||
is not restated here: it is read at load time from the apm-agent-allowlist
|
||||
section of references/field-inventory.md, which is the authoritative list.
|
||||
At project or user scope, <agent-file> is either half of a Claude Code .md /
|
||||
Copilot .agent.md pair.
|
||||
|
||||
Arguments:
|
||||
agent-file Path to the agent file (or either half of a project/user-scope pair).
|
||||
@@ -241,10 +243,13 @@ def check_apm_agent_file(fpath, allowlist, stem):
|
||||
fail(f"frontmatter still contains template HTML comments (<!-- ... -->) "
|
||||
f"— delete them before shipping — {local_fname}")
|
||||
|
||||
# Allowlist: only name/description/model may appear — no tools, no
|
||||
# Claude-only or Copilot-only fields. apm compile verbatim-copies
|
||||
# frontmatter to every target, so anything else is unsafe on at least
|
||||
# one harness (ADR-0016).
|
||||
# Allowlist: the permitted keys are data, read at load time from
|
||||
# references/field-inventory.md's `## apm-agent-allowlist` section — do not
|
||||
# restate them here, or this comment goes stale the next time that line
|
||||
# changes. apm compile verbatim-copies frontmatter to every target, so a key
|
||||
# outside the list is unsafe on at least one harness (ADR-0016). Note the
|
||||
# list admits denylist-shaped restrictions (disallowedTools) but never
|
||||
# allowlist-shaped ones (tools), whose value shape differs per harness.
|
||||
fm_keys = get_frontmatter_keys(fm)
|
||||
for key in sorted(fm_keys):
|
||||
if key not in allowlist:
|
||||
|
||||
@@ -38,8 +38,16 @@ bash scripts/new-agent.sh security-reviewer ~
|
||||
| `assets/templates/claude-code.md` | Annotated Claude Code agent definition template (project/user scope) |
|
||||
| `assets/templates/copilot.agent.md.template` | Annotated Copilot CLI agent definition template (project/user scope) |
|
||||
| `assets/templates/apm-agent.md` | Annotated vendor-neutral APM agent definition template (plugin/APM scope) |
|
||||
| `tests/new-agent.bats` | bats tests for `scripts/new-agent.sh` |
|
||||
| `tests/new-agent.bats` | (source-only) bats tests for `scripts/new-agent.sh` |
|
||||
| `assets/README.md` | Directory meta-documentation for assets/ |
|
||||
| `references/README.md` | Directory meta-documentation for references/ |
|
||||
| `scripts/README.md` | Directory meta-documentation for scripts/ |
|
||||
| `tests/README.md` | bats dependency instructions and run command |
|
||||
| `tests/README.md` | (source-only) bats dependency instructions and run command |
|
||||
|
||||
Rows marked **(source-only)** exist in the authoring source (`.apm/skills/agent-author/`) but are
|
||||
not present in an installed plugin: `scripts/sync-plugin-content.sh` strips
|
||||
`<category>/<name>/tests` when it generates the flat mirror, because these are dev-time fixtures no
|
||||
plugin host needs to discover (ADR-0017). Run them from a repo checkout, not from an install. The
|
||||
`assets/templates/` rows above are unaffected — the exclusion is depth-scoped to
|
||||
`<category>/<name>/tests`, so template trees that themselves contain a `tests/` directory ship
|
||||
intact.
|
||||
@@ -29,7 +29,9 @@ metadata:
|
||||
## Gotchas
|
||||
|
||||
- At plugin/APM scope, bump the resolved package's `apm.yml` `version` after every change — minor for a new agent, patch for a fix. Consumers compare this version to detect updates; skipping it hides the change.
|
||||
- At plugin/APM scope, `tools` and all Claude-only fields (`isolation`, `maxTurns`, `effort`, `memory`, `permissionMode`, `disallowedTools`, `skills`, `color`, `initialPrompt`, `background`, `hooks`, `mcpServers`) are omitted entirely, not merely restricted (ADR-0016: `apm compile` copies frontmatter verbatim to both harnesses with no per-target integrator, so a harness-specific value is wrong on at least one). Only project/user scope supports these fields.
|
||||
- At plugin/APM scope, `tools` and all Claude-only fields (`isolation`, `maxTurns`, `effort`, `memory`, `permissionMode`, `skills`, `color`, `initialPrompt`, `background`, `hooks`, `mcpServers`) are omitted entirely, not merely restricted (ADR-0016: `apm compile` copies frontmatter verbatim to both harnesses with no per-target integrator, so a harness-specific value is wrong on at least one). Only project/user scope supports these fields.
|
||||
- `disallowedTools` is the one exception, on **shape**, not favouritism. `tools` is an *allowlist* whose vocabulary differs per harness (Claude tool names vs Copilot's `execute`/`read`/`edit`/`search`/`agent`/`web`), so verbatim copy makes one value wrong on one target. A *denylist* cannot fail that way: an unrecognised name denies nothing, so the worst case is a missing fence, never a wrong grant. Claude Code honours it for plugin subagents — `docs/research/docs/claude-code-plugins/agent-definition.md:99` lists the three fields plugin agents ignore (`hooks`, `mcpServers`, `permissionMode`) and this is not one. Write it on every read-only plugin-scope agent (ADR-0016's 2026-08-14 amendment).
|
||||
- That fence is partial: it denies only the tools it names. It does not deny `Bash`, which a plugin-scope agent with no `tools` inherits, so a shell redirect still writes. Say the agent is read-only in the body too.
|
||||
- An `apm.yml` with no top-level `type:` field is a marketplace-only manifest, not a package root — the walk-up skips it and keeps going.
|
||||
- `AskUserQuestion`, `EnterPlanMode`, `ExitPlanMode`, `ScheduleWakeup`, and `WaitForMcpServers` are never available to any subagent regardless of the `tools` field. Exception: `ExitPlanMode` is available when the parent session runs in `permissionMode: plan`.
|
||||
- Duplicate `name` values in the same scope: Claude Code silently discards one without warning. Always verify uniqueness before shipping.
|
||||
@@ -90,7 +92,7 @@ The script is file-by-file no-op — it skips any file that already exists.
|
||||
|
||||
### Step 2 — Fill in the agent file(s)
|
||||
|
||||
**At plugin/APM scope**, there is exactly one file: `<package-root>/.apm/agents/<name>.agent.md`. Frontmatter carries ONLY `name`, `description`, optionally `model`, and optionally `source_keys` (provenance metadata, not a runtime field — see the template) — never `tools` or the other Claude-only fields listed in Gotchas (ADR-0016). Fill in `name`, `description`, `model`, and the system prompt body per the guidance below; the rest of this step's field-by-field guidance (tools, maxTurns, effort, memory, isolation, disallowedTools, skills, color, initialPrompt, background) is project/user scope only. Skip Step 3 and go to Step 4.
|
||||
**At plugin/APM scope**, there is exactly one file: `<package-root>/.apm/agents/<name>.agent.md`. Its frontmatter allowlist is the `apm-agent-allowlist` section of `agent-audit`'s `references/field-inventory.md`, read from there as data: `name`, `description`, `model`, `source_keys` (provenance metadata, not a runtime field — see the template), and `disallowedTools` for a read-only agent. Never `tools` or the other Claude-only fields listed in Gotchas (ADR-0016). Fill those in plus the system prompt body per the guidance below; the rest of this step's field-by-field guidance is project/user scope only. Skip Step 3 and go to Step 4.
|
||||
|
||||
**At project/user scope**, continue below to fill in both provider files — this step covers the Claude Code file (`<name>.md`); Step 3 covers the Copilot file.
|
||||
|
||||
@@ -106,13 +108,14 @@ Open the scaffolded Claude Code file. Replace every `FILL IN:` placeholder. **Re
|
||||
|
||||
**`tools`** (project/user scope only — never at plugin/APM scope) — restrict to what the agent actually needs. Omit to inherit all tools. Use `Agent(type1,type2)` to limit which subagent types this agent can spawn; omit `Agent` entirely to prevent spawning.
|
||||
|
||||
**Optional fields worth considering (project/user scope only — never at plugin/APM scope):**
|
||||
**`disallowedTools`** (all scopes, including plugin/APM) — denylist applied before `tools` and taking precedence over it; supports `mcp__<server>`, `mcp__<server>__*`, and `mcp__*` globs. `api-reference.md:40` types it `string / list` and `agent-definition.md:71` types it `string[]`, so a YAML list or a delimited string both work; this repo's plugin-scope agents use the comma-separated string (`disallowedTools: Edit, Write, NotebookEdit`) — match that.
|
||||
|
||||
**Optional fields worth considering (project/user scope only — never at plugin/APM scope, with the exception of `model`, which is allowed at every scope):**
|
||||
- `model`: set when this agent needs a different capability tier (`haiku` for fast tasks, `opus` for deep reasoning)
|
||||
- `maxTurns`: set a cap to prevent runaway agents on bounded tasks
|
||||
- `effort`: set to `low` for single-lookup tasks, `high` or above for deep reasoning or multi-file analysis — overrides session effort level; omit to inherit
|
||||
- `memory`: `user`, `project`, or `local` — only when cross-session state is genuinely needed
|
||||
- `isolation: worktree` — only when the agent modifies files and needs an isolated copy
|
||||
- `disallowedTools`: space-separated denylist applied before `tools`; supports `mcp__*` glob patterns (e.g. `disallowedTools: mcp__filesystem__*`)
|
||||
- `skills`: list of skill names preloaded at agent startup — different from the `source_keys` metadata field
|
||||
- `color`: UI color for the agent tile (`red`, `blue`, `green`, `yellow`, `purple`, `orange`, `pink`, `cyan`)
|
||||
- `initialPrompt`: auto-submitted as the first turn when this agent activates as the main session thread; only set when this agent is intended for main-thread activation
|
||||
@@ -152,7 +155,7 @@ Skip this step entirely at plugin/APM scope — there is no separate Copilot fil
|
||||
|
||||
**`source_keys`** — add the same top-level list as the CC file when research sources were used. Omit when no research was used.
|
||||
|
||||
**Remove all template documentation comments from the YAML frontmatter after filling in required fields** — these are marked with `<!--` and `-->` and must be deleted before shipping.
|
||||
**Delete the `<!-- -->` template comments from the frontmatter**, as in Step 2.
|
||||
|
||||
The system prompt body should match the Claude Code version — the agent's task definition is the same across providers.
|
||||
|
||||
@@ -183,37 +186,32 @@ If no research sources are in context, delete `sources.md`.
|
||||
|
||||
### Step 5 — Validate and close
|
||||
|
||||
Run this checklist before invoking the audit:
|
||||
Run this checklist before invoking the audit.
|
||||
|
||||
**Every file, at every scope:**
|
||||
- [ ] `name` present and kebab-case; `description` present
|
||||
- [ ] System prompt body present and non-empty
|
||||
- [ ] No `FILL IN:` placeholders and no `<!-- -->` template comments remain
|
||||
|
||||
**Plugin/APM scope — single file (`<name>.agent.md`):**
|
||||
- [ ] `name` field present, kebab-case, unique in scope
|
||||
- [ ] `description` field present and action-first
|
||||
- [ ] Frontmatter contains ONLY `name`, `description`, and optionally `model` (plus `source_keys` if research-sourced) — no `tools`, `isolation`, `maxTurns`, `effort`, `memory`, `permissionMode`, `disallowedTools`, `skills`, `color`, `initialPrompt`, `background`, `hooks`, or `mcpServers`
|
||||
- [ ] System prompt body present and non-empty
|
||||
- [ ] No `FILL IN:` placeholders remain
|
||||
- [ ] No `<!-- -->` template comments remain in frontmatter
|
||||
- [ ] `name` unique in scope; `description` action-first
|
||||
- [ ] Every frontmatter field is in the `apm-agent-allowlist` section of `agent-audit`'s `references/field-inventory.md` — the single source of truth, read as data by `validate.sh`. As of 2026-08-14: `name`, `description`, `model`, `source_keys`, `disallowedTools`. Nothing else — in particular no `tools`
|
||||
- [ ] A read-only agent carries `disallowedTools` **and** says so in the body
|
||||
|
||||
**Project/user scope — Claude Code file (`<name>.md`):**
|
||||
- [ ] `name` field present, kebab-case, unique in scope
|
||||
- [ ] `description` field present and action-first
|
||||
- [ ] System prompt body present and non-empty
|
||||
- [ ] No `FILL IN:` placeholders remain
|
||||
- [ ] No `<!-- -->` template comments remain in frontmatter
|
||||
- [ ] `name` unique in scope; `description` action-first
|
||||
|
||||
**Project/user scope — Copilot CLI file (`<name>.agent.md`):**
|
||||
- [ ] File extension is `.agent.md` (not `.md`)
|
||||
- [ ] `name` field matches the filename stem (e.g. `name: my-agent` in `my-agent.agent.md`)
|
||||
- [ ] `description` field present
|
||||
- [ ] `name` matches the filename stem (e.g. `name: my-agent` in `my-agent.agent.md`)
|
||||
- [ ] No Claude Code-only fields (`maxTurns`, `isolation`, `memory`, `permissionMode`, `effort`, `hooks`, `mcpServers`)
|
||||
- [ ] System prompt body present and non-empty
|
||||
- [ ] Body does not exceed 30,000 characters
|
||||
- [ ] No `<!-- -->` template comments remain in frontmatter
|
||||
|
||||
At plugin/APM scope, apply a **minor bump** to the resolved package's `apm.yml` `version` (single manifest, e.g. `1.0.4` → `1.1.0`).
|
||||
|
||||
Invoke `kyberforge:agent-audit` on the created file(s) before closing — validates the pair at project/user scope, the single file at plugin/APM scope.
|
||||
|
||||
**Commit verification.** Capture `git log --oneline -1` before Step 1 and keep it. Once the audit is clean, run `git add` and `git commit` for the new agent files — do not stop at staging. Then run `git log --oneline -1` again and confirm the hash changed from the one you captured at the start. A non-empty `git diff --stat` is not sufficient proof of completion: staged-but-uncommitted work isn't part of any commit and can be silently lost if the working tree is cleaned up before a commit lands. Only report the agent as done once the hash has actually changed.
|
||||
**Commit verification.** Once the audit is clean, run `git add` and `git commit` for the new agent files — do not stop at staging. Then confirm `git log --oneline -1` differs from the hash captured before Step 1. A non-empty `git diff --stat` is not proof of completion: staged-but-uncommitted work is part of no commit and can be silently lost if the working tree is cleaned up. Only report the agent as done once the hash has actually changed.
|
||||
|
||||
## Improving an existing agent
|
||||
|
||||
@@ -223,9 +221,7 @@ Confirm the agent files exist and at least one improvement signal is present in
|
||||
|
||||
If no signals: "This skill applies existing signals to an agent. For a blind review, examine the files manually or run a grill session first."
|
||||
|
||||
Verify `kyberforge:agent-audit` is available — it ships with the kyberforge plugin and is co-installed with this skill. If unavailable, stop and tell the user to install the kyberforge plugin before continuing.
|
||||
|
||||
Capture `git log --oneline -1` now, before making any edits — Step 5 needs it to verify a real commit landed.
|
||||
Verify `kyberforge:agent-audit` is available, as in the create flow's Prerequisites. Capture `git log --oneline -1` now, before making any edits — Step 5 needs it to verify a real commit landed.
|
||||
|
||||
**Partial state (project/user scope only)** — if one provider file exists but not the other, scaffold the missing one (`bash scripts/new-agent.sh <name> <root>`, file-by-file no-op) then continue. Doesn't apply at plugin/APM scope — single file, no partial-pair state.
|
||||
|
||||
@@ -248,7 +244,7 @@ Before editing, state which root causes were identified, what evidence supports
|
||||
|
||||
### Step 4 — Apply changes
|
||||
|
||||
Edit any file the signals point to. Generalize the fix — find the underlying gap, not the specific example that failed. For every sentence you add, ask: "Would the agent get this wrong without it?" A shorter, focused definition consistently outperforms an exhaustive one. For Copilot files, verify no Claude Code-only fields are introduced. For a plugin/APM-scope single file, verify no field beyond `name`, `description`, `model`, and `source_keys` is introduced.
|
||||
Edit any file the signals point to. Generalize the fix — find the underlying gap, not the specific example that failed. For every sentence you add, ask: "Would the agent get this wrong without it?" A shorter, focused definition consistently outperforms an exhaustive one. For Copilot files, verify no Claude Code-only fields are introduced. For a plugin/APM-scope single file, verify every field is still in the `apm-agent-allowlist` section of `agent-audit`'s `references/field-inventory.md`, and that an existing `disallowedTools` fence was not dropped by the edit.
|
||||
|
||||
If the edit adds or removes research-sourced content, update `source_keys` in the edited file(s) and the corresponding entry in `sources.md` per Create flow's Step 4.
|
||||
|
||||
@@ -260,4 +256,4 @@ At plugin/APM scope, apply a **patch bump** to the resolved package's `apm.yml`
|
||||
|
||||
Invoke `kyberforge:agent-audit` on the edited file(s) to confirm no regressions — the pair at project/user scope, the single file at plugin/APM scope.
|
||||
|
||||
**Commit verification.** Capture `git log --oneline -1` at the start of Step 1 and keep it. Once the audit is clean, run `git add` and `git commit` for the changed files — do not stop at staging. Then run `git log --oneline -1` again and confirm the hash changed from the one you captured at the start. A non-empty `git diff --stat` is not sufficient proof of completion: staged-but-uncommitted work isn't part of any commit and can be silently lost if the working tree is cleaned up before a commit lands. Only report the improvement as done once the hash has actually changed.
|
||||
**Commit verification.** Exactly as in the create flow's Step 5, against the hash captured at Step 1: commit the changed files once the audit is clean, and only report the improvement as done once `git log --oneline -1` shows a different hash.
|
||||
@@ -6,4 +6,4 @@ Annotated agent definition templates copied by `scripts/new-agent.sh` when scaff
|
||||
|
||||
- **`claude-code.md`** — Claude Code agent definition template (project/user scope). Includes all supported frontmatter fields (required and optional) with inline guidance comments and `FILL IN:` placeholders.
|
||||
- **`copilot.agent.md.template`** — Copilot CLI agent definition template (CLI format, project/user scope). Excludes cloud/IDE-only fields (`target`, `user-invocable`, `disable-model-invocation`, `mcp-servers`) and Claude Code-only fields. Uses Copilot tool aliases (`execute`, `read`, `edit`, `search`, `agent`, `web`).
|
||||
- **`apm-agent.md`** — Vendor-neutral APM agent definition template (plugin/APM scope). Only `name`, `description`, optional `model`, and optional `source_keys` (provenance metadata, not a runtime field) in frontmatter — no `tools` and no Claude-only fields, since `apm compile` copies frontmatter verbatim to both the Claude Code and Copilot CLI targets with no per-target integrator (ADR-0016).
|
||||
- **`apm-agent.md`** — Vendor-neutral APM agent definition template (plugin/APM scope). Frontmatter is limited to the `apm-agent-allowlist` section of `agent-audit`'s `references/field-inventory.md` — the authoritative list, read from there as data by `agent-audit`'s `validate.sh`; this file deliberately does not restate it. No `tools` and no Claude-only knobs, since `apm compile` copies frontmatter verbatim to both the Claude Code and Copilot CLI targets with no per-target integrator; `disallowedTools` is scaffolded as an opt-in comment because a denylist, unlike the `tools` allowlist, survives that copy (ADR-0016 and its 2026-08-14 amendment).
|
||||
@@ -2,18 +2,25 @@
|
||||
<!-- Vendor-neutral APM agent definition (plugin/APM scope).
|
||||
Path: <package-root>/.apm/agents/<name>.agent.md — one file, no counterpart.
|
||||
`apm compile` copies this frontmatter verbatim to BOTH the Claude Code and
|
||||
Copilot CLI targets — there is no per-target field integrator. Claude's
|
||||
`tools:` (space-separated string) and Copilot's `tools:` (alias list) are
|
||||
incompatible vocabularies, and Claude-only fields (isolation, maxTurns,
|
||||
effort, memory, permissionMode) have no Copilot equivalent. A value correct
|
||||
for one harness is guaranteed wrong on the other, so this scope carries
|
||||
ONLY the fields below — full stop (see ADR-0016). `source_keys` is
|
||||
provenance metadata, not a runtime field, and is exempt from that rule.
|
||||
Copilot CLI targets, with no per-target field integrator to reconcile
|
||||
anything, so a harness-specific value is wrong on at least one of them.
|
||||
|
||||
Do NOT add: tools, isolation, maxTurns, effort, memory, permissionMode,
|
||||
disallowedTools, skills, color, initialPrompt, background, hooks, or
|
||||
mcpServers. Omitting `tools` means inherit-all-tools on both harnesses,
|
||||
which is never wrong.
|
||||
This template does not restate the permitted-field list. The authoritative
|
||||
list is the `apm-agent-allowlist` section of agent-audit's
|
||||
references/field-inventory.md, which agent-audit's validate.sh reads from
|
||||
there as data — a list copied into a template goes stale one step further
|
||||
out than the list itself. Every field scaffolded below is on it; before
|
||||
adding any other field, check that section.
|
||||
|
||||
The shape rule behind the list (ADR-0016 and its 2026-08-14 amendment):
|
||||
`tools` is an ALLOWLIST whose vocabulary differs per harness — Claude tool
|
||||
names vs Copilot's execute/read/edit/search/agent/web — so one value is
|
||||
wrong on one target. Never add it here; omitting it means inherit-all-tools
|
||||
on both harnesses, which is never wrong. `disallowedTools` is a DENYLIST
|
||||
and is allowed for exactly that reason: a name the other harness does not
|
||||
recognise denies nothing, so the worst case is a missing fence, never a
|
||||
wrongly granted capability. Claude-only knobs (isolation, maxTurns, effort,
|
||||
memory, permissionMode) have no Copilot equivalent and stay out.
|
||||
|
||||
Fill in all FILL IN: placeholders. Delete template comments before shipping. -->
|
||||
|
||||
@@ -30,6 +37,16 @@ description: FILL IN: Action-first description of what this agent does and when
|
||||
Optional. Aliases: sonnet, opus, haiku, fable. Or full model ID.
|
||||
Omit to inherit the runtime default on whichever harness compiles this file. -->
|
||||
|
||||
<!-- disallowedTools: Edit, Write, NotebookEdit
|
||||
Optional. Denylist, applied before `tools` and taking precedence over it.
|
||||
Add it when this agent is read-only — it is the one tool restriction that
|
||||
survives verbatim copy (see the header comment). Claude Code honours it for
|
||||
plugin subagents — confirmed. Copilot's handling of the key is unconfirmed;
|
||||
ADR-0016 accepts that as a stated risk rather than a settled fact.
|
||||
It denies only the tools it names. It does NOT deny Bash, which this agent
|
||||
inherits, so a shell redirect still writes — state the read-only boundary
|
||||
in the system prompt body as well, not in frontmatter alone. -->
|
||||
|
||||
<!-- source_keys:
|
||||
- slug-name
|
||||
Development-only. Add when research sources informed this agent (slugs must match
|
||||
|
||||
@@ -14,8 +14,9 @@ description: FILL IN: Action-first description of what this agent does and when
|
||||
Be specific about the triggering condition and domain.
|
||||
Example: "Reviews pull request diffs for security issues. Use proactively after code changes." -->
|
||||
|
||||
<!-- tools: Read Bash Grep
|
||||
Optional. Space-separated allowlist. Omit to inherit all tools from parent.
|
||||
<!-- tools: Read, Bash, Grep
|
||||
Optional. Allowlist of tool names: a comma-separated string or a YAML list.
|
||||
Omit to inherit all tools from parent.
|
||||
Use Agent(type1,type2) to restrict which subagent types this agent can spawn.
|
||||
Omit Agent entirely to prevent this agent from spawning subagents.
|
||||
Never available to subagents regardless of tools field:
|
||||
@@ -47,9 +48,13 @@ description: FILL IN: Action-first description of what this agent does and when
|
||||
<!-- background: false
|
||||
Optional. Set true to force background execution. -->
|
||||
|
||||
<!-- disallowedTools: mcp__filesystem__write_file
|
||||
Optional. Space-separated denylist, applied before the tools allowlist.
|
||||
Supports mcp__* glob patterns (e.g. mcp__filesystem__* to block all filesystem tools). -->
|
||||
<!-- disallowedTools: Edit, Write, NotebookEdit
|
||||
Optional. Denylist, applied before the tools allowlist and taking precedence over it.
|
||||
Accepts a YAML list or a delimited string; use the comma-separated string form for
|
||||
consistency with the plugin-scope agents in this repo.
|
||||
Supports mcp__* glob patterns (e.g. mcp__filesystem__* to block all filesystem tools).
|
||||
Denies only the tools it names — it does not deny Bash, so an agent that inherits
|
||||
Bash can still write via a shell redirect. State read-only intent in the body too. -->
|
||||
|
||||
<!-- skills:
|
||||
- skill-name
|
||||
|
||||
@@ -6,7 +6,7 @@ source_keys: []
|
||||
|
||||
## deployment-modes.md
|
||||
|
||||
Agent scope hierarchy, precedence rules, and per-scope restrictions. Covers: which fields are silently ignored for plugin agents (Claude Code and Copilot CLI), scoped identifiers for plugin subdirectory agents, cache isolation behaviour, and Copilot CLI path conventions. Loaded conditionally from SKILL.md when the destination is a plugin directory.
|
||||
Agent scope hierarchy, precedence rules, and per-scope restrictions. Covers: which frontmatter fields a plugin/APM-scope agent may carry and the allowlist-vs-denylist shape rule that decides it (deferring to `agent-audit`'s `references/field-inventory.md` for the list itself), scoped identifiers for plugin subdirectory agents, cache isolation behaviour, and Copilot CLI path conventions. Loaded conditionally from SKILL.md when the destination is a plugin directory.
|
||||
|
||||
## scripts.md
|
||||
|
||||
|
||||
@@ -24,9 +24,11 @@ When the same agent `name` appears at multiple scopes, **user scope wins over pr
|
||||
|
||||
## Plugin scope restrictions
|
||||
|
||||
Plugin/APM agents (`.apm/agents/<name>.agent.md`) carry only `name`, `description`, optionally `model`, and optionally `source_keys` (provenance metadata, not a runtime field — silently ignored by both harnesses) in frontmatter — full stop (see ADR-0016). `apm compile` copies this frontmatter verbatim to both the Claude Code and Copilot CLI compile targets with no per-target integrator: Claude's `tools:` (space-separated string) and Copilot's `tools:` (alias list) are incompatible vocabularies, and Claude-only fields have no Copilot equivalent, so any harness-specific value is guaranteed wrong on at least one target.
|
||||
Plugin/APM agents (`.apm/agents/<name>.agent.md`) carry only the fields in the `apm-agent-allowlist` section of `agent-audit`'s `references/field-inventory.md`. That section is the authoritative list — `agent-audit`'s `validate.sh` reads it from there as data, and it changes — so consult it rather than any restatement of it. `apm compile` copies this frontmatter verbatim to both the Claude Code and Copilot CLI compile targets with no per-target integrator, so a harness-specific value is guaranteed wrong on at least one target (ADR-0016).
|
||||
|
||||
This makes the old "silently ignored at plugin scope" framing moot. It's not that `hooks`, `mcpServers`, `permissionMode`, `tools`, `isolation`, `maxTurns`, `effort`, `memory`, `disallowedTools`, `skills`, `color`, `initialPrompt`, or `background` are merely ignored at this scope — they are never written to the file at all. Copy the agent to `.claude/agents/` (project scope) or `~/.claude/agents/` (user scope) to use any of them.
|
||||
**The rule is about a field's shape, not a fixed roster.** `tools` is an **allowlist** whose vocabulary differs per harness — Claude Code names its own tools, Copilot CLI uses aliases (`execute`/`read`/`edit`/`search`/`agent`/`web`) — so under verbatim copy one value is wrong on one target. It stays out. `disallowedTools` is a **denylist**, and denying by name has no such conflict: a name the other harness does not recognise denies nothing, so the worst case is that the fence is absent there, never that a capability is wrongly granted. That asymmetry is why the denylist is admitted where the allowlist is not (ADR-0016's 2026-08-14 amendment). Claude Code honours it for plugin subagents — `docs/research/docs/claude-code-plugins/agent-definition.md:99` names the three fields plugin agents silently ignore (`hooks`, `mcpServers`, `permissionMode`) and `disallowedTools` is not among them. It is a partial fence: it denies only the tools it names, not `Bash`, which a plugin-scope agent with no `tools` inherits — so state read-only intent in the body too.
|
||||
|
||||
This makes the old "silently ignored at plugin scope" framing moot for the excluded fields. It's not that `hooks`, `mcpServers`, `permissionMode`, `tools`, `isolation`, `maxTurns`, `effort`, `memory`, `skills`, `color`, `initialPrompt`, or `background` are merely ignored at this scope — they are never written to the file at all. Copy the agent to `.claude/agents/` (project scope) or `~/.claude/agents/` (user scope) to use any of them.
|
||||
|
||||
## Scoped identifiers (Claude Code plugin agents only)
|
||||
|
||||
|
||||
@@ -15,6 +15,7 @@ All scripts in this skill must follow these rules:
|
||||
- **Idempotent** — "create if not exists" per file. The scaffold script skips any file that already exists; agents may safely re-run it.
|
||||
- **Meaningful exit codes** — `0` success, `1` invalid arguments or precondition failure. Document in `--help`.
|
||||
- **Self-contained** — no external package installs at runtime. The script uses only bash builtins and POSIX tools (`sed`, `mkdir`, `cat`).
|
||||
- **No restated field rosters** — no script output, in `--help` or in next-steps guidance, enumerates permitted, forbidden, or required frontmatter fields. Point at the `apm-agent-allowlist` section of `agent-audit`'s `references/field-inventory.md`, which `agent-audit`'s `validate.sh` reads from there as data. A roster copied into script output goes stale one step further out than the list itself: the next-steps hint `(name, description, model, body only)` kept printing after ADR-0016's 2026-08-14 amendment added `disallowedTools` to the permitted set. `tests/new-agent.bats` enforces this for the plugin/APM branch — naming some allowlisted fields but not all is a failure.
|
||||
|
||||
## Template variables
|
||||
|
||||
|
||||
@@ -10,4 +10,4 @@ Usage: new-agent.sh <agent-name> <root>
|
||||
|
||||
Resolves scope by walking up from `<root>`: a `type:`-bearing `apm.yml` found at or above `<root>` → plugin/APM scope (single file at `<package-root>/.apm/agents/<name>.agent.md`; an `apm.yml` without `type:` is a marketplace-only manifest and is skipped); `<root>` exactly `~` → user scope (`~/.claude/agents/` + `~/.copilot/agents/`); otherwise project scope (`<root>/.claude/agents/` + `<root>/.github/agents/`). Each file is a no-op if it already exists. See `--help` for full usage.
|
||||
|
||||
Tests: `tests/new-agent.bats` (requires `bats-support` and `bats-assert`).
|
||||
Tests: `tests/new-agent.bats` (requires `bats-support` and `bats-assert`) — source-only. `scripts/sync-plugin-content.sh` strips `<category>/<name>/tests` from the generated mirror (ADR-0017), so this file exists in a repo checkout of `.apm/skills/agent-author/` and not in an installed plugin.
|
||||
@@ -21,10 +21,12 @@ Arguments:
|
||||
without type: is a marketplace-only manifest
|
||||
and is skipped, the walk continues upward
|
||||
→ creates <package-root>/.apm/agents/<name>.agent.md
|
||||
(single vendor-neutral file — no tools,
|
||||
isolation, maxTurns, effort, memory, or
|
||||
permissionMode; apm compile has no per-target
|
||||
field integrator, see ADR-0016)
|
||||
(single vendor-neutral file; apm compile copies
|
||||
its frontmatter verbatim to every target with no
|
||||
per-target field integrator, so the permitted
|
||||
field set is narrow — see the apm-agent-allowlist
|
||||
section of agent-audit's
|
||||
references/field-inventory.md and ADR-0016)
|
||||
→ creates <package-root>/sources.md (if absent)
|
||||
project scope : no type:-bearing apm.yml found; root is a
|
||||
project directory
|
||||
@@ -258,6 +260,19 @@ SOURCES
|
||||
fi
|
||||
fi
|
||||
|
||||
# Next-steps guidance names no frontmatter fields, by rule (see references/scripts.md).
|
||||
# A roster restated in terminal output goes stale one step further out than the list
|
||||
# itself: the old "(name, description, model, body only)" hint outlived ADR-0016's
|
||||
# 2026-08-14 amendment, which added disallowedTools to the permitted set. Point at the
|
||||
# scaffolded file's own comments for what to fill, and at agent-audit's validate.sh —
|
||||
# which reads the allowlist from field-inventory.md as data — for what is permitted.
|
||||
AUDIT_SCRIPTS="$(cd "$SKILL_ROOT/../agent-audit/scripts" 2>/dev/null && pwd || true)"
|
||||
if [[ -n "$AUDIT_SCRIPTS" && -f "$AUDIT_SCRIPTS/validate.sh" ]]; then
|
||||
VALIDATE_HINT="$AUDIT_SCRIPTS/validate.sh"
|
||||
else
|
||||
VALIDATE_HINT="agent-audit's scripts/validate.sh"
|
||||
fi
|
||||
|
||||
if [[ "$created_any" == false ]]; then
|
||||
echo "All files already exist — nothing to do." >&2
|
||||
else
|
||||
@@ -266,12 +281,17 @@ else
|
||||
echo "" >&2
|
||||
echo "Next steps:" >&2
|
||||
if [[ "$SCOPE" == "plugin" ]]; then
|
||||
echo " 1. Fill in $APM_FILE — replace all FILL IN: placeholders (name, description, model, body only)" >&2
|
||||
echo " 1. Fill in $APM_FILE — replace every FILL IN: placeholder. Optional fields are" >&2
|
||||
echo " scaffolded there as commented blocks; uncomment the ones that apply." >&2
|
||||
echo " 2. Populate $SOURCES_DIR/sources.md with research sources, or delete it" >&2
|
||||
echo " 3. Validate: check required fields (name, description, system prompt) in the file" >&2
|
||||
echo " 3. Validate: $VALIDATE_HINT $APM_FILE" >&2
|
||||
echo " It checks the frontmatter against the apm-agent-allowlist section of" >&2
|
||||
echo " agent-audit's references/field-inventory.md, the authoritative field list." >&2
|
||||
else
|
||||
echo " 1. Fill in $CC_FILE — replace all FILL IN: placeholders" >&2
|
||||
echo " 2. Fill in $CP_FILE — replace all FILL IN: placeholders" >&2
|
||||
echo " 3. Validate: check required fields (name, description, system prompt) in both files" >&2
|
||||
echo " 1. Fill in $CC_FILE — replace every FILL IN: placeholder. Optional fields are" >&2
|
||||
echo " scaffolded there as commented blocks; uncomment the ones that apply." >&2
|
||||
echo " 2. Fill in $CP_FILE — same, and heed its closing comment: the Claude Code-only" >&2
|
||||
echo " fields it names must not cross over from the file above." >&2
|
||||
echo " 3. Validate: run $VALIDATE_HINT on each file" >&2
|
||||
fi
|
||||
fi
|
||||
@@ -33,6 +33,11 @@ Provide the path to the skill directory to audit when invoking.
|
||||
| `references/description-quality.md` | Spec-grounded rubric for description auditing — loaded when a finding is borderline |
|
||||
| `references/body-discipline.md` | Spec-grounded rubric for body discipline auditing — loaded when padding vs necessity is unclear |
|
||||
| `references/sources.md` | Provenance record — agentskills.io sources that informed this skill and which files each contributed to |
|
||||
| `tests/validate.bats` | Bats test suite for validate.sh |
|
||||
| `tests/validate-provenance.bats` | Bats test suite for validate-provenance.sh |
|
||||
| `tests/README.md` | Setup instructions for bats-support and bats-assert test dependencies |
|
||||
| `tests/validate.bats` | (source-only) Bats test suite for validate.sh |
|
||||
| `tests/validate-provenance.bats` | (source-only) Bats test suite for validate-provenance.sh |
|
||||
| `tests/README.md` | (source-only) Setup instructions for bats-support and bats-assert test dependencies |
|
||||
|
||||
Rows marked **(source-only)** exist in the authoring source (`.apm/skills/skill-audit/`) but are
|
||||
not present in an installed plugin: `scripts/sync-plugin-content.sh` strips
|
||||
`<category>/<name>/tests` when it generates the flat mirror, because these are dev-time fixtures no
|
||||
plugin host needs to discover (ADR-0017). Run them from a repo checkout, not from an install.
|
||||
@@ -46,8 +46,16 @@ If the destination resolves inside an APM package, read `references/deployment-m
|
||||
| `assets/templates/references/sources.md` | Sources provenance template for new skills |
|
||||
| `assets/templates/assets/README.md` | Placeholder for static assets |
|
||||
| `assets/templates/tests/README.md` | Placeholder for test files |
|
||||
| `tests/new-skill.bats` | Bats test suite for `scripts/new-skill.sh` |
|
||||
| `tests/README.md` | Setup instructions for bats-support and bats-assert test dependencies |
|
||||
| `tests/new-skill.bats` | (source-only) Bats test suite for `scripts/new-skill.sh` |
|
||||
| `tests/README.md` | (source-only) Setup instructions for bats-support and bats-assert test dependencies |
|
||||
|
||||
Rows marked **(source-only)** exist in the authoring source (`.apm/skills/skill-author/`) but are
|
||||
not present in an installed plugin: `scripts/sync-plugin-content.sh` strips
|
||||
`<category>/<name>/tests` when it generates the flat mirror, because these are dev-time fixtures no
|
||||
plugin host needs to discover (ADR-0017). Run them from a repo checkout, not from an install. The
|
||||
`assets/templates/tests/README.md` row above is **not** source-only — the exclusion is depth-scoped
|
||||
to `<category>/<name>/tests`, so the scaffolding template tree ships intact, which
|
||||
`scripts/new-skill.sh` depends on at runtime.
|
||||
|
||||
## Spec reference
|
||||
|
||||
|
||||
@@ -5,6 +5,8 @@ description: Runs a linter sweep over a target file or directory scope and repor
|
||||
|
||||
source_keys:
|
||||
- context7-websites-vale-sh
|
||||
|
||||
disallowedTools: Edit, Write, NotebookEdit
|
||||
---
|
||||
|
||||
You are a linter runner. When invoked, you run the appropriate linter(s) over the requested scope, collect their findings, and report them back in a structured, reviewable form. You never edit files.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "lint",
|
||||
"version": "1.1.5",
|
||||
"version": "1.1.6",
|
||||
"description": "Skills and agents for configuring and running linters.",
|
||||
"author": {
|
||||
"name": "Defame1297",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "lint",
|
||||
"version": "1.1.5",
|
||||
"version": "1.1.6",
|
||||
"description": "Skills and agents for configuring and running linters.",
|
||||
"author": {
|
||||
"name": "Defame1297",
|
||||
|
||||
@@ -5,6 +5,8 @@ description: Runs a linter sweep over a target file or directory scope and repor
|
||||
|
||||
source_keys:
|
||||
- context7-websites-vale-sh
|
||||
|
||||
disallowedTools: Edit, Write, NotebookEdit
|
||||
---
|
||||
|
||||
You are a linter runner. When invoked, you run the appropriate linter(s) over the requested scope, collect their findings, and report them back in a structured, reviewable form. You never edit files.
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
name: lint
|
||||
version: 1.1.5
|
||||
version: 1.1.6
|
||||
description: Skills and agents for configuring and running linters.
|
||||
author:
|
||||
name: Defame1297
|
||||
|
||||
Executable
+161
@@ -0,0 +1,161 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
# Run agent-audit's validate.sh over every REAL plugin-scope agent file in this
|
||||
# repo (plugins/*/.apm/agents/*.agent.md).
|
||||
#
|
||||
# Why this exists: validate.sh was previously exercised only by
|
||||
# scripts/check-scope-walkup-sync.sh, and only against synthetic fixtures built
|
||||
# in mktemp trees. It had never once run against the four agent files it
|
||||
# actually governs. That is how ADR-0016 could be amended to bless a
|
||||
# `disallowedTools` frontmatter field while validate.sh's allowlist still
|
||||
# rejected it -- the spec and its enforcer disagreed, every gate stayed green,
|
||||
# and the contradiction only surfaced when someone ran the validator by hand.
|
||||
#
|
||||
# A validator that checks fixtures but never artifacts is the same
|
||||
# green-because-nothing-was-checked shape as a suite that runs on an empty file
|
||||
# set. This closes it: the artifacts are the input.
|
||||
#
|
||||
# Run from repo root, or pass REPO_ROOT as the first argument (tests do).
|
||||
# Needs no network.
|
||||
|
||||
REPO_ROOT="${1:-$(git rev-parse --show-toplevel 2>/dev/null || pwd)}"
|
||||
|
||||
# A nonexistent REPO_ROOT must fail loudly rather than fall through to the
|
||||
# zero-files floor below with a confusing message -- a typo'd or stale path is a
|
||||
# different problem from a repo that genuinely has no agents, and the fix
|
||||
# differs too.
|
||||
if [[ ! -d "$REPO_ROOT" ]]; then
|
||||
echo "APM agent validation failed: REPO_ROOT '$REPO_ROOT' is not a directory." >&2
|
||||
exit 1
|
||||
fi
|
||||
REPO_ROOT="$(cd "$REPO_ROOT" && pwd)"
|
||||
|
||||
VALIDATE="$REPO_ROOT/plugins/kyberforge/.apm/skills/agent-audit/scripts/validate.sh"
|
||||
|
||||
# The validator's own absence is a hard failure, never a skip. If validate.sh
|
||||
# moves or is deleted, every assertion below evaporates and the hook would
|
||||
# otherwise exit 0 having validated nothing -- indistinguishable, from
|
||||
# pre-commit's silent-on-pass output, from a run where all four agents passed.
|
||||
if [[ ! -f "$VALIDATE" ]]; then
|
||||
echo "APM agent validation failed: validator not found at $VALIDATE — this script's path has gone stale, so no agent file was checked. Update it to wherever agent-audit's validate.sh now lives." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# validate.sh is a bash wrapper around a heredoc'd python3 program. Without
|
||||
# python3 it dies with a bare "command not found" per file and no pointer, which
|
||||
# reads like a validation failure rather than a missing dependency. Fail closed,
|
||||
# but say which it is.
|
||||
if ! command -v python3 >/dev/null 2>&1; then
|
||||
echo "APM agent validation failed: python3 not found on PATH — agent-audit's validate.sh is a python3 program and cannot run. Install python3; this gate does not degrade to a pass." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# --- Discover the agent files ---
|
||||
# `-not -path` mirrors tests/run-bats.sh: worktrees under .claude/ are other
|
||||
# checkouts of this same repo, not additional content.
|
||||
AGENT_FILES=()
|
||||
while IFS= read -r f; do
|
||||
[[ -n "$f" ]] && AGENT_FILES+=("$f")
|
||||
done < <(
|
||||
find "$REPO_ROOT/plugins" -type f -name '*.agent.md' \
|
||||
-path '*/.apm/agents/*' \
|
||||
-not -path '*/.claude/worktrees/*' \
|
||||
2>/dev/null | sort
|
||||
)
|
||||
|
||||
# --- Derive the EXPECTED set from the index, not from a hardcoded count ---
|
||||
# Same reasoning as tests/run-bats.sh: a magic number goes stale the moment a
|
||||
# plugin gains or loses an agent, and slack in a floor is exactly where a
|
||||
# silently-deleted file hides. `git ls-files` needs no maintenance -- a newly
|
||||
# `git add`ed agent file joins the expectation immediately.
|
||||
#
|
||||
# Direction matters, and it is the same direction run-bats.sh uses: every
|
||||
# TRACKED file must have been discovered, but a discovered file need not be
|
||||
# tracked. An untracked new agent file is ordinary work in progress (and is
|
||||
# still validated below), while a file that vanished from the worktree without
|
||||
# leaving the index is an accident and fails here. A deliberate `git rm` leaves
|
||||
# the index, so intentional removal passes.
|
||||
#
|
||||
# The exact-equality check on --show-toplevel keeps this off the mktemp fixture
|
||||
# trees in tests/test-check-apm-agents-valid.sh, which resolve no worktree. That
|
||||
# degradation is announced rather than silent, and the zero-file floor below is
|
||||
# unconditional regardless.
|
||||
EXPECTED_FILES=()
|
||||
DERIVED=false
|
||||
GIT_TOPLEVEL="$(git -C "$REPO_ROOT" rev-parse --show-toplevel 2>/dev/null || true)"
|
||||
if [[ -n "$GIT_TOPLEVEL" && "$GIT_TOPLEVEL" == "$REPO_ROOT" ]]; then
|
||||
DERIVED=true
|
||||
while IFS= read -r f; do
|
||||
[[ -n "$f" ]] && EXPECTED_FILES+=("$REPO_ROOT/$f")
|
||||
done < <(
|
||||
git -C "$REPO_ROOT" ls-files -- 'plugins/*/.apm/agents/*.agent.md' \
|
||||
| grep -Ev '(^|/)\.claude/worktrees/' \
|
||||
| sort || true
|
||||
)
|
||||
else
|
||||
echo "Note: $REPO_ROOT is not a git worktree root, so the expected agent file set could not be derived from the index — only the zero-file floor below applies" >&2
|
||||
fi
|
||||
|
||||
if [[ "$DERIVED" == true && ${#EXPECTED_FILES[@]} -gt 0 ]]; then
|
||||
MISSING=()
|
||||
for expected in ${EXPECTED_FILES[@]+"${EXPECTED_FILES[@]}"}; do
|
||||
found=false
|
||||
for actual in ${AGENT_FILES[@]+"${AGENT_FILES[@]}"}; do
|
||||
if [[ "$actual" == "$expected" ]]; then
|
||||
found=true
|
||||
break
|
||||
fi
|
||||
done
|
||||
[[ "$found" == true ]] || MISSING+=("${expected#"$REPO_ROOT"/}")
|
||||
done
|
||||
if [[ ${#MISSING[@]} -gt 0 ]]; then
|
||||
echo "APM agent validation failed: ${#MISSING[@]} of ${#EXPECTED_FILES[@]} tracked agent file(s) were not discovered under $REPO_ROOT — they were deleted without being removed from the index, or this script's search path no longer reaches them:" >&2
|
||||
for m in ${MISSING[@]+"${MISSING[@]}"}; do
|
||||
echo " $m" >&2
|
||||
done
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
# Unconditional floor, separate from the derived check above: a tree with
|
||||
# nothing tracked (a tarball export, a fresh scaffold, a moved plugins/ root)
|
||||
# still must not validate an empty set and call it green. Zero files is an
|
||||
# error, not a pass -- that is the entire defect this script was written to
|
||||
# close, one level up.
|
||||
if [[ ${#AGENT_FILES[@]} -eq 0 ]]; then
|
||||
echo "APM agent validation failed: found 0 plugin-scope agent file(s) under $REPO_ROOT/plugins — the search path is wrong or every .apm/agents/ directory has been emptied. Zero files is never a pass." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# --- Validate ---
|
||||
FAIL=0
|
||||
FAILED_FILES=()
|
||||
for f in ${AGENT_FILES[@]+"${AGENT_FILES[@]}"}; do
|
||||
rel="${f#"$REPO_ROOT"/}"
|
||||
# validate.sh prints its FAIL lines on stdout and its own errors on stderr;
|
||||
# both are captured and replayed under the filename so the reason travels with
|
||||
# the file that caused it. pre-commit shows a failing hook's output verbatim,
|
||||
# so this is what a developer reads.
|
||||
out=""
|
||||
rc=0
|
||||
out="$(bash "$VALIDATE" "$f" 2>&1)" || rc=$?
|
||||
if [[ "$rc" -ne 0 ]]; then
|
||||
FAIL=1
|
||||
FAILED_FILES+=("$rel")
|
||||
echo "FAIL: $rel (validate.sh exit $rc)" >&2
|
||||
if [[ -n "$out" ]]; then
|
||||
printf '%s\n' "$out" | sed 's/^/ /' >&2
|
||||
else
|
||||
echo " (validate.sh produced no output — see its exit code above; 2 means script error, e.g. a missing references/field-inventory.md)" >&2
|
||||
fi
|
||||
fi
|
||||
done
|
||||
|
||||
if [[ "$FAIL" -ne 0 ]]; then
|
||||
echo "" >&2
|
||||
echo "APM agent validation failed: ${#FAILED_FILES[@]} of ${#AGENT_FILES[@]} agent file(s) did not pass agent-audit's validate.sh." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "APM agent validation passed: ${#AGENT_FILES[@]} plugin-scope agent file(s) validated against agent-audit's validate.sh."
|
||||
+161
-37
@@ -29,8 +29,29 @@ set -euo pipefail
|
||||
# compiled-output drift of exactly the kind ADR-0017 wires pre-push gates for -- and it
|
||||
# is the same plugin set the validate-plugins pre-commit hook already globs as
|
||||
# plugins/*/.
|
||||
#
|
||||
# Every pass above reads its plugin set out of marketplace.json, so anything that makes
|
||||
# that file yield nothing -- absent, unparseable, a non-object root, or an entry whose
|
||||
# `source` is neither a path string nor a remote object -- used to read as "clean"
|
||||
# rather than "unchecked". The same is true one level down, of a per-plugin
|
||||
# .claude-plugin/plugin.json that does not parse: it aborted the walk mid-loop and left
|
||||
# every later plugin silently unchecked. The guards below turn each of those into an
|
||||
# explicit, attributable failure instead, because a vacuous pass is the one result a gate
|
||||
# must never produce.
|
||||
|
||||
REPO_ROOT="${1:-$(git rev-parse --show-toplevel 2>/dev/null || pwd)}"
|
||||
# Hard error, not a `|| pwd` fallback, for the reason spelled out in
|
||||
# scripts/sync-marketplace-mirror.sh: every path below hangs off REPO_ROOT, and the
|
||||
# exit-0 path is "nothing on disk and no manifest", so a REPO_ROOT pointing somewhere
|
||||
# that is not this repo reports "clean" over a tree it never looked at. Run this from
|
||||
# an empty directory outside any worktree and the fallback made that the literal
|
||||
# outcome -- rev-parse failed, REPO_ROOT became $PWD, no plugins/ and no
|
||||
# marketplace.json were found, exit 0, silent.
|
||||
if [[ -n "${1:-}" ]]; then
|
||||
REPO_ROOT="$1"
|
||||
elif ! REPO_ROOT="$(git rev-parse --show-toplevel 2>/dev/null)" || [[ -z "$REPO_ROOT" ]]; then
|
||||
echo "Error: not inside a git worktree -- cannot locate the repository root, and guessing \$PWD would let this check report \"clean\" over a tree it never inspected. Run it from within the repository, or pass the repo root as an argument." >&2
|
||||
exit 1
|
||||
fi
|
||||
FAIL=0
|
||||
|
||||
err() { echo " FAIL: $1" >&2; FAIL=$((FAIL + 1)); }
|
||||
@@ -41,15 +62,117 @@ if ! command -v jq &>/dev/null; then
|
||||
fi
|
||||
|
||||
MARKETPLACE="$REPO_ROOT/.claude-plugin/marketplace.json"
|
||||
if [[ ! -f "$MARKETPLACE" ]]; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
# Repo-root-relative, not script-dir-relative -- see tests/run-tests.sh for why.
|
||||
# shellcheck source=scripts/lib/marketplace-plugins.sh
|
||||
source "$SCRIPT_DIR/lib/marketplace-plugins.sh"
|
||||
|
||||
# Candidate plugin directories on disk. The trigger is any of the three markers that
|
||||
# make a directory a plugin rather than scratch -- apm.yml (the ADR-0015 authoring
|
||||
# source), .apm/ (its content tree), or a compiled .claude-plugin/plugin.json. Matching
|
||||
# all three keeps this set aligned with the plugins/*/ glob the validate-plugins
|
||||
# pre-commit hook uses, which is the disagreement the disk -> marketplace pass below
|
||||
# exists to close; a directory with none of them is scratch and stays out of scope.
|
||||
#
|
||||
# It is collected before the marketplace is read because a missing marketplace.json is
|
||||
# only "nothing to check" when there is also nothing on disk to check against it.
|
||||
PLUGIN_DIRS=()
|
||||
for candidate in "$REPO_ROOT"/plugins/*/; do
|
||||
candidate="${candidate%/}"
|
||||
[[ -d "$candidate" ]] || continue
|
||||
if [[ ! -f "$candidate/apm.yml" && ! -d "$candidate/.apm" && ! -f "$candidate/.claude-plugin/plugin.json" ]]; then
|
||||
continue
|
||||
fi
|
||||
PLUGIN_DIRS+=("$candidate")
|
||||
done
|
||||
|
||||
# An absent marketplace.json used to exit 0 unconditionally -- the same empty-set-reads-
|
||||
# as-pass shape this script's other passes were fixed for. Per ADR-0015 the manifest is
|
||||
# compiled output of root apm.yml's marketplace.packages[], so its absence alongside
|
||||
# on-disk packages is drift, not an opt-out: it leaves every marketplace-derived gate
|
||||
# (this one and sync-plugin-content.sh --all) walking an empty plugin set in silence.
|
||||
if [[ ! -f "$MARKETPLACE" ]]; then
|
||||
if [[ ${#PLUGIN_DIRS[@]} -eq 0 ]]; then
|
||||
exit 0
|
||||
fi
|
||||
listing=""
|
||||
# Guarded expansion even though the check above makes an empty array unreachable
|
||||
# here: bash 3.2 under `set -u` aborts on a bare expansion of an empty array, and
|
||||
# tests/test-vale-wrap.sh's bash32_glob scan is line-based, so a guard two lines up
|
||||
# cannot clear it. Same form as the disk -> marketplace loop below.
|
||||
for candidate in ${PLUGIN_DIRS[@]+"${PLUGIN_DIRS[@]}"}; do
|
||||
listing+="${listing:+, }${candidate#"$REPO_ROOT"/}"
|
||||
done
|
||||
err ".claude-plugin/marketplace.json does not exist, but plugins/ holds ${#PLUGIN_DIRS[@]} plugin directory/ies ($listing) — every marketplace-derived check (this one, and sync-plugin-content.sh --all) silently walks an empty plugin set without it. Recompile the manifests from root apm.yml with \`apm pack\`."
|
||||
echo "Manifest check failed: $FAIL error(s)" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Preconditions the marketplace walk below cannot report on itself: it runs inside a
|
||||
# process substitution, so an abort in there is swallowed (see the helper's comment).
|
||||
assert_marketplace_manifest_usable "$MARKETPLACE"
|
||||
|
||||
# Validates one plugin.json pointer field against disk, for the non-apm fallback below.
|
||||
#
|
||||
# check_pointer_field <plugin_name> <plugin_dir> <field> <test_flag>
|
||||
#
|
||||
# test_flag is `test`'s: -d where only a directory is meaningful, -e otherwise.
|
||||
#
|
||||
# Per the vendored host docs (plugins/kyberforge/docs/research/docs/
|
||||
# claude-code-plugins/configuration.md and .../github-copilot-plugins/configuration.md)
|
||||
# these fields are legally `string | string[] | object`. Reading them with
|
||||
# `jq -r ".$field // empty"` collapsed the array and object shapes to their
|
||||
# pretty-printed JSON text, which then matched no path on disk -- a manifest that
|
||||
# resolves fine reported as broken. Reading `.skills | length` was worse than wrong: on
|
||||
# a (legal) string value it returned the character count, and the `.skills[$i]` that
|
||||
# followed aborted the whole script mid-loop under `set -e` with no summary line, so
|
||||
# every plugin later in the marketplace went unchecked.
|
||||
#
|
||||
# The bare `$(jq ...)` assignments below are safe only because the caller has already
|
||||
# established that $manifest parses AND that its root is an object (see the
|
||||
# precondition in the marketplace walk). Do not call this without that check: `set -e`
|
||||
# turns any jq failure in here into the same silent mid-loop abort described above.
|
||||
check_pointer_field() {
|
||||
local name="$1" plugin_dir="$2" field="$3" test_flag="$4"
|
||||
local manifest="$plugin_dir/.claude-plugin/plugin.json"
|
||||
local field_type count i elem_type
|
||||
|
||||
field_type="$(jq -r ".${field} | type" "$manifest")"
|
||||
case "$field_type" in
|
||||
null) ;;
|
||||
# An inline definition (a hooks or mcpServers object written straight into the
|
||||
# manifest) declares no path, so there is nothing on disk to resolve.
|
||||
object) ;;
|
||||
string)
|
||||
check_pointer_path "$name" "$plugin_dir" "$field" "$(jq -r ".${field}" "$manifest")" "$test_flag"
|
||||
;;
|
||||
array)
|
||||
count="$(jq ".${field} | length" "$manifest")"
|
||||
for ((i = 0; i < count; i++)); do
|
||||
elem_type="$(jq -r ".${field}[$i] | type" "$manifest")"
|
||||
if [[ "$elem_type" != "string" ]]; then
|
||||
err "plugin '$name': ${field}[$i] must be a path string, got $elem_type"
|
||||
continue
|
||||
fi
|
||||
check_pointer_path "$name" "$plugin_dir" "$field" "$(jq -r ".${field}[$i]" "$manifest")" "$test_flag"
|
||||
done
|
||||
;;
|
||||
*)
|
||||
err "plugin '$name': $field must be a path string, an array of path strings, or an inline object, got $field_type"
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
check_pointer_path() {
|
||||
local name="$1" plugin_dir="$2" field="$3" ref="$4" test_flag="$5"
|
||||
local full_path="$plugin_dir/$ref"
|
||||
full_path="${full_path%/}"
|
||||
if ! test "$test_flag" "$full_path"; then
|
||||
err "plugin '$name': $field path not found: $ref"
|
||||
fi
|
||||
}
|
||||
|
||||
# Every local plugin directory marketplace.json claimed, canonicalized, so the
|
||||
# disk -> marketplace pass below can tell "listed" from "unlisted" regardless of how
|
||||
# the `source:` string was spelled (./plugins/x, plugins/x, plugins/x/).
|
||||
@@ -76,35 +199,35 @@ while IFS=$'\t' read -r name plugin_dir; do
|
||||
# job (see header comment above).
|
||||
[[ -d "$plugin_dir/.apm" ]] && continue
|
||||
|
||||
# Precondition for check_pointer_field, which reads the manifest with bare
|
||||
# `field_type="$(jq ... )"` assignments. Under `set -e` a jq failure in one of
|
||||
# those aborts the whole script mid-loop: rc=5, a raw `jq: parse error` and no
|
||||
# `Manifest check failed:` summary, with every later plugin left unchecked --
|
||||
# the same failure class the marketplace's own `jq empty` precondition closes,
|
||||
# for a file that is equally generated output. Both shapes have to be caught
|
||||
# here: `jq empty` passes on a valid non-object document like `[]` or `123`, and
|
||||
# it is the `.skills` lookup on such a root ("Cannot index array with string")
|
||||
# that aborts, not the parse.
|
||||
if ! jq empty "$manifest" >/dev/null 2>&1; then
|
||||
err "plugin '$name': .claude-plugin/plugin.json is not valid JSON — it is compiled output, so recompile it with \`apm pack\`."
|
||||
continue
|
||||
fi
|
||||
manifest_type="$(jq -r 'type' "$manifest")"
|
||||
if [[ "$manifest_type" != "object" ]]; then
|
||||
err "plugin '$name': .claude-plugin/plugin.json is a JSON $manifest_type at its top level; expected an object."
|
||||
continue
|
||||
fi
|
||||
|
||||
# Fallback for a non-apm plugin: validate that any skills/hooks/mcpServers/agents
|
||||
# pointer fields in its hand-authored plugin.json still resolve to real paths.
|
||||
skill_count=$(jq '.skills | if . then length else 0 end' "$manifest")
|
||||
for ((s = 0; s < skill_count; s++)); do
|
||||
skill_path=$(jq -r ".skills[$s]" "$manifest")
|
||||
full_path="$plugin_dir/$skill_path"
|
||||
full_path="${full_path%/}"
|
||||
if [[ ! -d "$full_path" ]]; then
|
||||
err "plugin '$name': skills path not found: $skill_path"
|
||||
fi
|
||||
done
|
||||
|
||||
for field in hooks mcpServers agents; do
|
||||
ref=$(jq -r ".${field} // empty" "$manifest")
|
||||
[[ -z "$ref" ]] && continue
|
||||
full_path="$plugin_dir/$ref"
|
||||
full_path="${full_path%/}"
|
||||
if [[ ! -e "$full_path" ]]; then
|
||||
err "plugin '$name': $field path not found: $ref"
|
||||
fi
|
||||
done
|
||||
# skills/agents point at directories; hooks/mcpServers may point at a file.
|
||||
check_pointer_field "$name" "$plugin_dir" skills -d
|
||||
check_pointer_field "$name" "$plugin_dir" agents -e
|
||||
check_pointer_field "$name" "$plugin_dir" hooks -e
|
||||
check_pointer_field "$name" "$plugin_dir" mcpServers -e
|
||||
done < <(list_marketplace_local_plugins "$REPO_ROOT" "$MARKETPLACE")
|
||||
|
||||
# Disk -> marketplace. The trigger is any of the three markers that make a directory
|
||||
# a plugin rather than scratch -- apm.yml (the ADR-0015 authoring source), .apm/ (its
|
||||
# content tree), or a compiled .claude-plugin/plugin.json. Matching all three keeps
|
||||
# this set aligned with the plugins/*/ glob the validate-plugins pre-commit hook uses,
|
||||
# which is the disagreement this check exists to close; a directory with none of them
|
||||
# is scratch and stays out of scope.
|
||||
# Disk -> marketplace, over the PLUGIN_DIRS candidate set collected above.
|
||||
#
|
||||
# A candidate counts as listed if it is either a directory some local entry pointed at
|
||||
# (path match, canonicalized above) or a directory whose name matches a REMOTE entry's
|
||||
@@ -117,18 +240,19 @@ done < <(list_marketplace_local_plugins "$REPO_ROOT" "$MARKETPLACE")
|
||||
# the basename of the directory it points at: an entry named "beta" pointing at
|
||||
# ./plugins/alpha would mark an unrelated, entirely unlisted plugins/beta/ as listed.
|
||||
# Local entries already have an exact path to match on, so they need no name fallback.
|
||||
#
|
||||
# The select is an allowlist of the object shape, not a denylist of the string one --
|
||||
# see list_marketplace_remote_plugin_names in scripts/lib/marketplace-plugins.sh, which
|
||||
# owns it, and tests/test-check-manifests.sh, which exercises it directly against
|
||||
# malformed entries rather than through this caller (where
|
||||
# assert_marketplace_manifest_usable rejects them first, and so would mask a regression
|
||||
# in the select itself).
|
||||
MARKETPLACE_NAMES=()
|
||||
while IFS= read -r entry_name; do
|
||||
[[ -n "$entry_name" ]] && MARKETPLACE_NAMES+=("$entry_name")
|
||||
done < <(jq -r '.plugins[]? | select((.source | type) != "string") | .name // empty' "$MARKETPLACE")
|
||||
|
||||
for candidate in "$REPO_ROOT"/plugins/*/; do
|
||||
candidate="${candidate%/}"
|
||||
[[ -d "$candidate" ]] || continue
|
||||
if [[ ! -f "$candidate/apm.yml" && ! -d "$candidate/.apm" && ! -f "$candidate/.claude-plugin/plugin.json" ]]; then
|
||||
continue
|
||||
fi
|
||||
done < <(list_marketplace_remote_plugin_names "$MARKETPLACE")
|
||||
|
||||
for candidate in ${PLUGIN_DIRS[@]+"${PLUGIN_DIRS[@]}"}; do
|
||||
candidate_abs="$(cd "$candidate" && pwd -P)"
|
||||
candidate_name="$(basename "$candidate")"
|
||||
listed=0
|
||||
|
||||
@@ -31,8 +31,19 @@ NEW_SKILL="$REPO_ROOT/plugins/kyberforge/.apm/skills/skill-author/scripts/new-sk
|
||||
VALIDATE="$REPO_ROOT/plugins/kyberforge/.apm/skills/agent-audit/scripts/validate.sh"
|
||||
VALIDATE_PROVENANCE="$REPO_ROOT/plugins/kyberforge/.apm/skills/agent-audit/scripts/validate-provenance.sh"
|
||||
|
||||
# Floor on the four hardcoded `plugins/kyberforge/.apm/...` paths above. A
|
||||
# missing target is only a legitimate no-op for a repo that has no kyberforge
|
||||
# plugin at all; if `plugins/kyberforge/` IS here and the `.apm/` script under it
|
||||
# is not, these paths have gone stale and every fixture below silently does not
|
||||
# run. The whole exit is 0 either way, so a stale path is indistinguishable from
|
||||
# "all four implementations agree" — and a path rewrite is exactly the kind of
|
||||
# edit that would slip through it. Same reasoning as the REPO_ROOT guard above.
|
||||
for f in "$NEW_AGENT" "$NEW_SKILL" "$VALIDATE" "$VALIDATE_PROVENANCE"; do
|
||||
if [[ ! -f "$f" ]]; then
|
||||
if [[ -d "$REPO_ROOT/plugins/kyberforge" ]]; then
|
||||
echo "Scope walk-up sync check failed: $REPO_ROOT/plugins/kyberforge exists but $f does not — this script's .apm/ paths have gone stale, so none of the walk-up fixtures ran. Update them to wherever the agent-author/agent-audit/skill-author scripts now live." >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "Scope walk-up sync check: $f not found — kyberforge agent-author/agent-audit/skill-author skills not present, nothing to check." >&2
|
||||
exit 0
|
||||
fi
|
||||
|
||||
@@ -28,7 +28,19 @@ err() { echo " FAIL: $1" >&2; FAIL=$((FAIL + 1)); }
|
||||
SKILL_AUDIT="$REPO_ROOT/plugins/kyberforge/.apm/skills/skill-audit"
|
||||
AGENT_AUDIT="$REPO_ROOT/plugins/kyberforge/.apm/skills/agent-audit"
|
||||
|
||||
# Floor on the hardcoded `plugins/kyberforge/.apm/...` paths above. Neither copy
|
||||
# present is only a legitimate no-op for a repo that has no kyberforge plugin at
|
||||
# all. If `plugins/kyberforge/` IS here and the `.apm/` targets under it are not,
|
||||
# the paths in this script have gone stale — a rename or relocation of `.apm/`
|
||||
# would otherwise turn every assertion below into a silent exit 0, which reads as
|
||||
# "checked, in sync" exactly like the REPO_ROOT case above. That matters most for
|
||||
# the change that introduced these paths: a path rewrite is precisely the edit
|
||||
# this would survive unnoticed.
|
||||
if [[ ! -d "$SKILL_AUDIT" && ! -d "$AGENT_AUDIT" ]]; then
|
||||
if [[ -d "$REPO_ROOT/plugins/kyberforge" ]]; then
|
||||
echo "Vale style sync check failed: $REPO_ROOT/plugins/kyberforge exists but neither $SKILL_AUDIT nor $AGENT_AUDIT does — this script's .apm/ paths have gone stale, so nothing was checked. Update them to wherever the audit skills now live." >&2
|
||||
exit 1
|
||||
fi
|
||||
exit 0
|
||||
fi
|
||||
|
||||
@@ -65,6 +77,13 @@ fi
|
||||
SKILL_INI="$SKILL_AUDIT/assets/vale/.vale.ini"
|
||||
AGENT_INI="$AGENT_AUDIT/assets/vale/.vale.ini"
|
||||
|
||||
# Counted, not assumed. The summary line at the bottom used to hardcode `2
|
||||
# .vale.ini file(s) checked` in both branches. That was true on any clean run --
|
||||
# a missing or unreadable file errs below and the script never reaches the
|
||||
# summary -- but the line's whole purpose is to say what this run actually
|
||||
# inspected, and a constant says what the author expected. Nothing asserted it,
|
||||
# so it would have survived becoming false.
|
||||
INIS_CHECKED=0
|
||||
for ini in "$SKILL_INI" "$AGENT_INI"; do
|
||||
rel_ini="${ini#"$REPO_ROOT"/}"
|
||||
# `-e`, not `-f`: a path that exists but is not a readable regular file (a
|
||||
@@ -94,6 +113,9 @@ for ini in "$SKILL_INI" "$AGENT_INI"; do
|
||||
err "$rel_ini exists but could not be read — none of its assertions could run, and an unreadable file cannot be distinguished from a clean one downstream"
|
||||
continue
|
||||
fi
|
||||
# Counted here, past both `continue`s: the file exists and its bytes were
|
||||
# readable, so every assertion below it really does run against it.
|
||||
INIS_CHECKED=$((INIS_CHECKED + 1))
|
||||
# StylesPath is resolved relative to the .vale.ini, which is the only reason
|
||||
# the bundled styles are found from a consuming repo's clone prefix.
|
||||
if ! grep -Eq '^[[:space:]]*StylesPath[[:space:]]*=[[:space:]]*styles[[:space:]]*$' "$ini"; then
|
||||
@@ -183,28 +205,14 @@ fi
|
||||
# it — silently masking exactly the kind of hook-rescoping drift this script
|
||||
# exists to catch.
|
||||
#
|
||||
# Cached per (skill, manifest) pair (parallel HOOK_REGEX_CACHE_KEYS/_VALS
|
||||
# arrays, populated lazily) because the final validation loop below probes
|
||||
# agent-audit's two manifests across three probe shapes; without the cache,
|
||||
# each repeated (skill, manifest) pairing would re-parse the same manifest
|
||||
# file from scratch for no new information. Plain indexed arrays, not
|
||||
# `declare -A`: associative arrays are bash 4.0+ and this script must run on
|
||||
# macOS's stock bash 3.2. Only ${#arr[@]} (always safe on an empty/unset array
|
||||
# under `set -u`) and index access are used below — never a bare `${arr[@]}`
|
||||
# expansion, which aborts on bash < 4.4 under nounset.
|
||||
HOOK_REGEX_CACHE_KEYS=()
|
||||
HOOK_REGEX_CACHE_VALS=()
|
||||
# Deliberately NOT memoized. The probe loop below calls this 12 times over the
|
||||
# same two small manifests, which measures at 14ms against a ~870ms run (the six
|
||||
# vale invocations are the wall clock). A previous memoization attempt was inert
|
||||
# anyway: every call site is `x="$(hook_file_regexes ...)"`, a command
|
||||
# substitution, so the cache writes landed in a subshell and the lookup never
|
||||
# hit. Re-parsing is the honest, working version of a saving too small to buy.
|
||||
hook_file_regexes() {
|
||||
local skill="$1" manifest="$2" raw result idx=0
|
||||
local cache_key="$skill|$manifest"
|
||||
while [[ $idx -lt ${#HOOK_REGEX_CACHE_KEYS[@]} ]]; do
|
||||
if [[ "${HOOK_REGEX_CACHE_KEYS[$idx]}" == "$cache_key" ]]; then
|
||||
printf '%s' "${HOOK_REGEX_CACHE_VALS[$idx]}"
|
||||
return
|
||||
fi
|
||||
idx=$((idx + 1))
|
||||
done
|
||||
result="$(
|
||||
local skill="$1" manifest="$2" raw
|
||||
if [[ -f "$manifest" ]]; then
|
||||
awk -v skill="$skill" '
|
||||
function flush() {
|
||||
@@ -222,10 +230,6 @@ hook_file_regexes() {
|
||||
printf '%s\n' "$raw"
|
||||
done
|
||||
fi
|
||||
)"
|
||||
HOOK_REGEX_CACHE_KEYS[${#HOOK_REGEX_CACHE_KEYS[@]}]="$cache_key"
|
||||
HOOK_REGEX_CACHE_VALS[${#HOOK_REGEX_CACHE_VALS[@]}]="$result"
|
||||
printf '%s' "$result"
|
||||
}
|
||||
|
||||
# True if $1 matches at least one newline-delimited regex in $2.
|
||||
@@ -248,8 +252,16 @@ EOF_RE
|
||||
# carries a description with a token Kyberforge.VagueWording flags, so a config
|
||||
# whose glob matches but whose BasedOnStyles lost Kyberforge fails too: it would
|
||||
# lint the file and report nothing.
|
||||
# On a miss, the caller reports a glob defect -- but a miss is also what a failed
|
||||
# exec, an OOM-killed vale, or a full TMPDIR looks like, and discarding vale's rc
|
||||
# and output made those indistinguishable and evidence-free. A flake seen once in
|
||||
# this probe could not be diagnosed afterwards for exactly that reason. The rc and
|
||||
# output are now stashed for the caller to attach to its message; VALE_PROBE_DIAG
|
||||
# is set on every call, so a stale value from an earlier probe can never be
|
||||
# reported against a later one.
|
||||
VALE_PROBE_DIAG=""
|
||||
vale_flags_path() {
|
||||
local cfg="$1" rel="$2" tmp out
|
||||
local cfg="$1" rel="$2" tmp out rc=0
|
||||
tmp="$(mktemp -d)"
|
||||
mkdir -p "$tmp/$(dirname "$rel")"
|
||||
{
|
||||
@@ -260,15 +272,40 @@ vale_flags_path() {
|
||||
echo ""
|
||||
echo "Body."
|
||||
} > "$tmp/$rel"
|
||||
out="$(cd "$tmp" && vale --config "$cfg" "$rel" 2>&1)" || true
|
||||
out="$(cd "$tmp" && vale --config "$cfg" "$rel" 2>&1)" || rc=$?
|
||||
rm -rf "$tmp"
|
||||
printf '%s\n' "$out" | grep -qF "Kyberforge.VagueWording"
|
||||
if printf '%s\n' "$out" | grep -qF "Kyberforge.VagueWording"; then
|
||||
VALE_PROBE_DIAG=""
|
||||
return 0
|
||||
fi
|
||||
# vale exits nonzero merely for *having* alerts, so rc alone proves nothing --
|
||||
# it is evidence only alongside the absent alert.
|
||||
VALE_PROBE_DIAG="vale exited $rc; output: ${out:-<empty>}"
|
||||
return 1
|
||||
}
|
||||
|
||||
# Missing vale is a HARD FAILURE, not a warning. Six of this script's assertions
|
||||
# — one glob probe per path below — are `vale --config` invocations, and they are
|
||||
# the only ones that catch the defect the whole `.vale.ini coverage` section was
|
||||
# written for: the one-character glob typo (`[**/SKILL.md]` -> `[**/SKILLS.md]`)
|
||||
# that leaves every text-level assertion clean while vale lints zero files. As a
|
||||
# warning this self-disabled on exactly that mutation and exited 0, and since
|
||||
# pre-commit swallows a passing hook's output the stderr line was never seen —
|
||||
# the pre-push hook reported `Passed`. That is the same "clean exit 0 reads as
|
||||
# 'checked, in sync' when nothing ran" failure the REPO_ROOT guard at the top of
|
||||
# this file already refuses to allow.
|
||||
#
|
||||
# The opt-out exists for a machine that genuinely cannot install vale, and it is
|
||||
# an env var that has to be set on purpose — never mere absence of the binary.
|
||||
# Setting it downgrades the run to text-level assertions only and says so.
|
||||
VALE_AVAILABLE=true
|
||||
if ! command -v vale >/dev/null 2>&1; then
|
||||
VALE_AVAILABLE=false
|
||||
echo " WARNING: vale is not installed — .vale.ini glob coverage was NOT verified. Install it (https://vale.sh/docs/vale-cli/installation/) before trusting a clean run." >&2
|
||||
if [[ "${CHECK_VALE_STYLE_SYNC_ALLOW_MISSING_VALE:-}" == "1" ]]; then
|
||||
echo " WARNING: vale is not installed and CHECK_VALE_STYLE_SYNC_ALLOW_MISSING_VALE=1 — .vale.ini glob coverage was NOT verified, only the text-level assertions ran. A clean result here does not mean the globs cover what their hooks lint." >&2
|
||||
else
|
||||
err "vale is not installed, so none of the .vale.ini glob-coverage probes ran — a glob typo that silently lints zero files is invisible without them. Install it (https://vale.sh/docs/vale-cli/installation/), or set CHECK_VALE_STYLE_SYNC_ALLOW_MISSING_VALE=1 to accept a text-only run"
|
||||
fi
|
||||
fi
|
||||
|
||||
# One representative path per file shape the prefilter is supposed to cover,
|
||||
@@ -284,11 +321,13 @@ fi
|
||||
# narrow out of sync with .pre-commit-hooks.yaml's without either manifest's
|
||||
# own hook breaking (each still matches real files on its own), so nothing
|
||||
# else would catch it.
|
||||
PROBES_CHECKED=0
|
||||
while IFS='|' read -r skill rel scope; do
|
||||
[[ -n "$skill" ]] || continue
|
||||
dir="$REPO_ROOT/plugins/kyberforge/.apm/skills/$skill"
|
||||
ini="$dir/assets/vale/.vale.ini"
|
||||
[[ -f "$ini" ]] || continue
|
||||
PROBES_CHECKED=$((PROBES_CHECKED + 1))
|
||||
|
||||
hooks_regexes="$(hook_file_regexes "$skill" "$REPO_ROOT/.pre-commit-hooks.yaml")"
|
||||
config_regexes="$(hook_file_regexes "$skill" "$REPO_ROOT/.pre-commit-config.yaml")"
|
||||
@@ -304,7 +343,7 @@ while IFS='|' read -r skill rel scope; do
|
||||
fi
|
||||
|
||||
if [[ "$VALE_AVAILABLE" == true ]] && ! vale_flags_path "$ini" "$rel"; then
|
||||
err "$skill/assets/vale/.vale.ini raises no Kyberforge alert on $rel — its glob sections do not cover a path its own pre-commit hook is scoped to, so the hook passes that shape without linting it"
|
||||
err "$skill/assets/vale/.vale.ini raises no Kyberforge alert on $rel — its glob sections do not cover a path its own pre-commit hook is scoped to, so the hook passes that shape without linting it [$VALE_PROBE_DIAG]"
|
||||
fi
|
||||
# `demo.md` (bare, no `.agent.md` suffix) is `hooks-only` rather than
|
||||
# `shared`: it exists only to exercise agent-audit's `[**/agents/*.md]` glob
|
||||
@@ -334,7 +373,40 @@ agent-audit|.claude/agents/demo.md|hooks-only
|
||||
agent-audit|copilot/demo.agent.md|hooks-only
|
||||
EOF_PROBE
|
||||
|
||||
# Second floor, on the probe TABLE rather than on the directory paths. Every row
|
||||
# `continue`s when the `.vale.ini` of the skill its first column names is absent,
|
||||
# so the table can verify nothing while FAIL stays 0. Two states do that, and no
|
||||
# other assertion in this file sees either:
|
||||
#
|
||||
# * the `EOF_PROBE` heredoc gutted — a bad merge, a truncated edit, or a
|
||||
# wholesale delete of the rows. The loop body never runs at all.
|
||||
# * every row's skill column drifting away from the directory names on disk
|
||||
# (`skill-audit|` -> `skill-auditX|`), which is what a skill rename plus a
|
||||
# half-applied find/replace leaves behind.
|
||||
#
|
||||
# Both give a clean exit 0 from a section that checked nothing, which is why the
|
||||
# guard is worth having. What it is NOT reachable by is a relocation of
|
||||
# `assets/vale/`: PROBES_CHECKED only reaches 0 that way if BOTH `.vale.ini`
|
||||
# files are gone, and the loop at the top of the `.vale.ini coverage` section
|
||||
# errs on each of them first, so that state is already FAIL >= 2 and this guard
|
||||
# is never the cause. The message therefore names the table, not the files —
|
||||
# describing it as "every probe skill's .vale.ini is missing" misdiagnosed the
|
||||
# one thing that can actually trigger it.
|
||||
if [[ $PROBES_CHECKED -eq 0 ]]; then
|
||||
err "no probe path was checked — the probe table is empty, or no row's first column names a skill directory under plugins/kyberforge/.apm/skills/ that has an assets/vale/.vale.ini, so the glob-coverage section verified nothing at all"
|
||||
fi
|
||||
|
||||
if [[ $FAIL -gt 0 ]]; then
|
||||
echo "Vale style sync check failed: $FAIL error(s). For a drifted wrapper or style, agent-audit's copy is canonical — run scripts/sync-vale-styles.sh to regenerate skill-audit's copy, then commit both. A .vale.ini finding is not drift and sync-vale-styles.sh will not fix it: edit that file's own StylesPath, BasedOnStyles or glob sections." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# A clean run says what it actually inspected. Silence is what let the vacuous
|
||||
# passes above look identical to real ones, and it is what made "did this script
|
||||
# do any work against the real repo?" untestable from outside — the counts below
|
||||
# are what tests/test-check-vale-style-sync.sh asserts a non-zero floor on.
|
||||
if [[ "$VALE_AVAILABLE" == true ]]; then
|
||||
echo "Vale style sync check passed: $INIS_CHECKED .vale.ini file(s) checked, $PROBES_CHECKED glob probe(s) verified with vale."
|
||||
else
|
||||
echo "Vale style sync check passed (text-level only, vale unavailable): $INIS_CHECKED .vale.ini file(s) checked, 0 glob probe(s) verified."
|
||||
fi
|
||||
@@ -32,22 +32,60 @@ batch_jobs_limit() {
|
||||
# directly) -- both patterns are preserved as-is by callers, not standardized
|
||||
# here, so existing error-handling behavior (including how each pattern
|
||||
# interacts with `set -e` in the caller) is unchanged by this extraction.
|
||||
#
|
||||
# batch_run waits ONLY on the PIDs it started, never with a bare `wait`. A bare
|
||||
# `wait` blocks on every background job of the calling shell, so a caller that
|
||||
# backgrounds anything of its own would (a) have batch_run block until that
|
||||
# unrelated job finished and (b) have that job reaped here, with its exit status
|
||||
# consumed by the wrong `wait` -- leaving the caller's later `wait $pid` to fail
|
||||
# with "not a child of this shell". None of the three current callers backgrounds
|
||||
# anything else, so this was latent rather than live, but it was an undocumented
|
||||
# constraint on every future caller. Recording each `$!` and waiting on it by PID
|
||||
# removes the constraint instead of documenting it.
|
||||
|
||||
# batch_wait_pids [<pid> ...]
|
||||
# Reaps exactly the given PIDs and always returns 0.
|
||||
#
|
||||
# The `|| true` is load-bearing, not defensive noise: unlike a bare `wait`
|
||||
# (which is unconditionally 0), `wait <pid>` returns that job's exit status, so
|
||||
# without it a single failing job would make batch_run return nonzero and abort
|
||||
# its `set -e` caller at the call site -- before the caller could read the
|
||||
# .status files and print its own summary. Status semantics stay entirely in
|
||||
# the .status files, exactly as before.
|
||||
#
|
||||
# `${@+"$@"}` rather than a bare `"$@"`, for the same reason every `${arr[@]}`
|
||||
# in this repo carries the `${arr[@]+...}` guard. Bash 4.4 is what relaxed
|
||||
# `set -u` for an all-empty `@`/`*` expansion (CHANGES, 4.4 "New Features in
|
||||
# Bash" 3a); 3.2 predates that relaxation, and no bash on a modern machine can
|
||||
# reproduce the abort, so the guarded spelling is asserted rather than tested.
|
||||
# Zero args is a normal path here, not an edge case: the trailing call receives
|
||||
# an empty list whenever the job count divides evenly into the concurrency cap.
|
||||
batch_wait_pids() {
|
||||
local pid
|
||||
for pid in ${@+"$@"}; do
|
||||
wait "$pid" || true
|
||||
done
|
||||
}
|
||||
|
||||
batch_run() {
|
||||
local scratch_dir="$1"
|
||||
shift
|
||||
local jobs_limit running key cmd
|
||||
local jobs_limit running key cmd pids
|
||||
jobs_limit="$(batch_jobs_limit)"
|
||||
running=0
|
||||
pids=()
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
key="$1" cmd="$2"
|
||||
shift 2
|
||||
(eval "$cmd") >"$scratch_dir/$key.log" 2>&1 &
|
||||
pids+=("$!")
|
||||
running=$((running + 1))
|
||||
if [[ $running -ge $jobs_limit ]]; then
|
||||
wait
|
||||
batch_wait_pids ${pids[@]+"${pids[@]}"}
|
||||
pids=()
|
||||
running=0
|
||||
fi
|
||||
done
|
||||
wait
|
||||
batch_wait_pids ${pids[@]+"${pids[@]}"}
|
||||
}
|
||||
@@ -7,6 +7,65 @@
|
||||
#
|
||||
# Requires jq. Not meant to be executed directly -- source it.
|
||||
|
||||
# assert_marketplace_manifest_usable <marketplace_json_path>
|
||||
#
|
||||
# Checks the preconditions list_marketplace_local_plugins depends on but cannot
|
||||
# report on. Both callers run the walk inside a process substitution
|
||||
# (`done < <(list_marketplace_local_plugins ...)`), which is its own subshell: a
|
||||
# jq abort in there kills only that subshell, so an unparseable manifest yields
|
||||
# zero lines and reads exactly like "this marketplace declares no local plugins".
|
||||
# The caller then blames whatever its empty-set branch blames -- for
|
||||
# check-manifests.sh, every plugin directory on disk being unlisted.
|
||||
#
|
||||
# It also rejects an entry whose `source` is neither a local path string nor a
|
||||
# remote source object. Only those two shapes are classifiable: the walk below
|
||||
# takes the string ones, and the object ones are remote. Anything else -- absent
|
||||
# (`null`), a number, an array, a boolean -- is neither, so it silently drops out
|
||||
# of every marketplace-derived work list: this walk's and, through it,
|
||||
# sync-plugin-content.sh --all's.
|
||||
#
|
||||
# The check is deliberately typed as "not string AND not object" rather than
|
||||
# enumerating `.source == null`. Rejecting null specifically left every other
|
||||
# malformed value (`"source": 42`, `"source": []`) passing the assert, skipped by
|
||||
# the walk below, AND rescued by check-manifests.sh's disk -> marketplace name
|
||||
# axis -- i.e. exactly the defect the null case was fixed for, reached with a
|
||||
# different value.
|
||||
#
|
||||
# Exits 1 with a specific message on any violation, so call it from the caller's
|
||||
# own shell -- never inside `< <(...)`, which is the exact swallowing this guards.
|
||||
assert_marketplace_manifest_usable() {
|
||||
local marketplace="$1" root_type plugins_type unclassifiable
|
||||
|
||||
if ! jq empty "$marketplace" >/dev/null 2>&1; then
|
||||
echo "Error: $marketplace is not valid JSON -- every marketplace-derived check reads as \"no plugins declared\" until it parses. Fix it, or recompile it with \`apm pack\`." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# `jq empty` passes on any valid JSON document, including `[]`, `"x"` and `123`.
|
||||
# The `.plugins` lookup on the next line then aborts with a raw
|
||||
# `jq: error: Cannot index array with string "plugins"` and rc=5, attributed to
|
||||
# nothing -- so assert the root shape here, where it can be named.
|
||||
root_type="$(jq -r 'type' "$marketplace")"
|
||||
if [[ "$root_type" != "object" ]]; then
|
||||
echo "Error: $marketplace is a JSON $root_type at its top level; expected an object with a \`plugins\` array. Recompile it with \`apm pack\`." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
plugins_type="$(jq -r '.plugins | type' "$marketplace")"
|
||||
if [[ "$plugins_type" != "array" && "$plugins_type" != "null" ]]; then
|
||||
echo "Error: $marketplace has a \`plugins\` field of type $plugins_type; expected an array of plugin entries." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
unclassifiable="$(jq -r '[.plugins[]?
|
||||
| select((.source | type) as $t | $t != "string" and $t != "object")
|
||||
| "\(.name // "<unnamed>") (source: \(.source | type))"] | join(", ")' "$marketplace")"
|
||||
if [[ -n "$unclassifiable" ]]; then
|
||||
echo "Error: $marketplace has entries whose \`source\` is neither a local path string nor a remote source object: $unclassifiable. Such an entry is neither local nor remote, so it is skipped by every marketplace-derived check while still claiming its name. Fix it in root apm.yml's marketplace.packages[] and recompile." >&2
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
# list_marketplace_local_plugins <repo_root> <marketplace_json_path>
|
||||
#
|
||||
# Prints one "<name>\t<absolute_plugin_dir>" line per local (string `source:`)
|
||||
@@ -26,3 +85,26 @@ list_marketplace_local_plugins() {
|
||||
printf '%s\t%s\n' "$name" "$repo_root/$source"
|
||||
done
|
||||
}
|
||||
|
||||
# list_marketplace_remote_plugin_names <marketplace_json_path>
|
||||
#
|
||||
# Prints the `name` of every REMOTE (object `source:`) marketplace entry, one per
|
||||
# line -- the exact complement of list_marketplace_local_plugins.
|
||||
#
|
||||
# check-manifests.sh's disk -> marketplace pass uses it as its name axis: a plugin
|
||||
# vendored on disk but declared with the remote-object shape has no local entry to
|
||||
# path-match against, so without a name match it would be reported as unlisted when
|
||||
# its entry is in fact right there.
|
||||
#
|
||||
# The select is `(.source | type) == "object"`, an allowlist of the one shape that
|
||||
# axis is actually for -- NOT the denylist `(.source | type) != "string"` it used to
|
||||
# be. That denylist was true for `null` (and for numbers, arrays, booleans), so a
|
||||
# malformed entry marked its same-named directory "listed" while the local walk above
|
||||
# skipped it for lacking a string source: one bad entry disabled BOTH directions of
|
||||
# the check at once. assert_marketplace_manifest_usable rejects those shapes too, but
|
||||
# this function must be correct on its own -- it is called from a different script,
|
||||
# and a precondition that stops running is not a property of this select.
|
||||
list_marketplace_remote_plugin_names() {
|
||||
local marketplace="$1"
|
||||
jq -r '.plugins[]? | select((.source | type) == "object") | .name // empty' "$marketplace"
|
||||
}
|
||||
@@ -12,7 +12,18 @@ set -euo pipefail
|
||||
# script keeps that legacy mirror byte-identical to .claude-plugin/marketplace.json
|
||||
# instead of letting it silently drift (see issue #90 comment thread).
|
||||
|
||||
REPO_ROOT="$(git rev-parse --show-toplevel 2>/dev/null || pwd)"
|
||||
# Hard error, not a `|| pwd` fallback. Every path this script touches hangs off
|
||||
# REPO_ROOT, and both of its exits-0 paths are "the files agree" or "neither file
|
||||
# exists" -- so a REPO_ROOT pointing somewhere that is not this repo reports "no
|
||||
# drift" over a tree it never looked at. Run `--check` from an empty directory
|
||||
# outside any worktree and the fallback made that the literal outcome: rev-parse
|
||||
# failed, REPO_ROOT became $PWD, neither file was there, exit 0. Refusing to guess
|
||||
# is the only answer that cannot be silently wrong; the `-f "$DST"` branch below
|
||||
# covers a genuinely stale mirror, which is a different condition.
|
||||
if ! REPO_ROOT="$(git rev-parse --show-toplevel 2>/dev/null)" || [[ -z "$REPO_ROOT" ]]; then
|
||||
echo "Error: not inside a git worktree -- cannot locate the repository root, and guessing \$PWD would let --check report \"no drift\" over a tree it never inspected. Run this from within the repository." >&2
|
||||
exit 1
|
||||
fi
|
||||
SRC="$REPO_ROOT/.claude-plugin/marketplace.json"
|
||||
DST="$REPO_ROOT/.github/plugin/marketplace.json"
|
||||
|
||||
@@ -32,9 +43,9 @@ if [[ ! -f "$SRC" ]]; then
|
||||
# A missing source with a surviving mirror is drift, not absence: the mirror
|
||||
# can only be stale (nothing is left for it to be byte-identical to), which is
|
||||
# precisely the silent divergence this script exists to prevent. Exiting 0
|
||||
# here would report "no drift" over a mirror of a file that no longer exists,
|
||||
# and would also swallow the case where REPO_ROOT resolved to the wrong tree —
|
||||
# `git rev-parse --show-toplevel` falls back to `pwd` outside a worktree.
|
||||
# here would report "no drift" over a mirror of a file that no longer exists.
|
||||
# (An unresolvable REPO_ROOT is handled above and is a hard error; this branch
|
||||
# is only about a source file that is genuinely gone from a real worktree.)
|
||||
# scripts/sync-plugin-content.sh --check --all already errors on the same
|
||||
# condition ("requires .../marketplace.json"); this matches it.
|
||||
# Neither file present stays a genuine no-op: nothing to mirror, nothing stale.
|
||||
|
||||
+320
-21
@@ -56,11 +56,33 @@ set -euo pipefail
|
||||
# `hooks/hooks.json` "at the plugin root, not inside .claude-plugin/"
|
||||
# (plugins/kyberforge/docs/research/docs/claude-code-plugins/configuration.md's "Plugin
|
||||
# Directory Layout" table; quoted verbatim in ADR-0017's own root-cause analysis), and
|
||||
# the compiled plugin.json carries no `hooks` pointer to override that -- apm's
|
||||
# build_plugin_manifest strips pointer fields unconditionally, and re-injecting one is
|
||||
# the option ADR-0017 explicitly rejected. A root-level hooks.json (this script's own
|
||||
# pre-fix output shape) is therefore scanned by nothing at all, and is deleted as stale
|
||||
# by a real sync / reported as drift by --check.
|
||||
# the compiled plugin.json carries no `hooks` pointer to override that. apm emits none:
|
||||
# `hooks` is not in build_plugin_manifest's strip list at all (that list is
|
||||
# agents/skills/commands/instructions, and it is dead code besides -- see ADR-0017's
|
||||
# "Considered options"); apm.yml simply has no key that produces one. A root-level
|
||||
# hooks.json (this script's own pre-fix output shape) is therefore scanned by nothing at
|
||||
# all, and is deleted as stale by a real sync / reported as drift by --check.
|
||||
#
|
||||
# NO `hooks` POINTER IS RE-INJECTED into .github/plugin/plugin.json, deliberately, and
|
||||
# this is NOT the same call as mcpServers above. Copilot types `hooks` "string or object"
|
||||
# with no default, exactly like mcpServers, so Copilot resolves no hooks from any plugin
|
||||
# here -- but the two ecosystems' hooks FILE FORMATS are mutually incompatible (Claude:
|
||||
# `{"hooks": {"PreToolUse": [{matcher, hooks:[...]}]}}`; Copilot: `{"version": 1,
|
||||
# "hooks": {"sessionStart": [{type, bash, powershell, ...}]}}`), and apm's exporter
|
||||
# merges .apm/hooks/*.json into exactly ONE hooks.json with no per-target shaping
|
||||
# (_collect_hooks_from_apm, apm_cli/bundle/plugin_exporter.py). A pointer would therefore
|
||||
# assert that a Claude-shaped file is Copilot-shaped. .mcp.json carries no such claim --
|
||||
# it is one host-agnostic format both ecosystems read. See ADR-0017's 2026-08-14
|
||||
# "no `hooks` pointer" amendment; plugins/kyberforge/docs/hooks.md carries the
|
||||
# author-facing version.
|
||||
#
|
||||
# SYMLINKS UNDER .apm/ ARE NOT MIRRORED and cannot be: apm's bundle exporter filters
|
||||
# every symlink out of the bundle it produces (`f.is_file() and not f.is_symlink()` in
|
||||
# _collect_flat/_collect_recursive, and the same test in _collect_hooks_from_apm), with
|
||||
# no warning. Nothing downstream of the bundle can see the omission -- both sides of
|
||||
# --check's diff are built from that same bundle, so sync and --check agree the symlink
|
||||
# never existed. check_apm_symlinks below therefore reads the .apm/ SOURCE tree directly,
|
||||
# which is the only place the loss is visible, and reports it in both modes.
|
||||
#
|
||||
# tests/ subdirectories (e.g. .apm/skills/<name>/tests/*.bats) are excluded from the
|
||||
# mirror -- they are dev-time fixtures a plugin host never needs to discover, and several
|
||||
@@ -131,6 +153,14 @@ source "$SCRIPT_DIR/lib/batch-run.sh"
|
||||
# directory apm never emits and no plugin host ever scans.
|
||||
MIRROR_DIRS=(agents skills commands instructions extensions)
|
||||
|
||||
# The .apm/ SOURCE directories apm's exporter reads to build the bundle -- the input
|
||||
# side of MIRROR_DIRS, and deliberately a different list: `prompts` is here because
|
||||
# apm reads it (folding it into commands/), and `hooks` is here because
|
||||
# _collect_hooks_from_apm reads it. Used only by check_apm_symlinks, which needs to
|
||||
# know which parts of .apm/ are mirror INPUT: a symlink under a directory apm never
|
||||
# reads loses nothing and must not be reported as loss.
|
||||
APM_SOURCE_DIRS=(agents skills prompts commands instructions extensions hooks)
|
||||
|
||||
# The generated hooks directory, the merged hooks file inside it (Claude Code's
|
||||
# convention-scanned path), and the pre-fix root-level path a real sync now cleans
|
||||
# up as stale.
|
||||
@@ -142,22 +172,64 @@ FAIL=0
|
||||
SCRATCH_ROOT="$(mktemp -d)"
|
||||
trap 'rm -rf "$SCRATCH_ROOT"' EXIT
|
||||
|
||||
# The manifest comparison in check_path_modes needs octal permission bits, and
|
||||
# GNU coreutils and BSD/macOS stat disagree on both the flag and the format
|
||||
# specifier. Probe once at startup against a path known to exist rather than
|
||||
# branching on `uname` (which says nothing about which coreutils is installed --
|
||||
# GNU stat is perfectly common on macOS via Homebrew).
|
||||
declare -a STAT_MODE_ARGS=()
|
||||
if [[ "$(stat -c '%a' "$SCRIPT_DIR" 2>/dev/null)" =~ ^[0-7]+$ ]]; then
|
||||
STAT_MODE_ARGS=(-c '%a')
|
||||
elif [[ "$(stat -f '%Lp' "$SCRIPT_DIR" 2>/dev/null)" =~ ^[0-7]+$ ]]; then
|
||||
STAT_MODE_ARGS=(-f '%Lp')
|
||||
else
|
||||
# Hard error rather than degrading to a no-mode manifest: silently checking
|
||||
# less than advertised is the exact failure mode this gate exists to prevent.
|
||||
echo "Error: cannot read octal file modes -- neither \`stat -c '%a'\` (GNU coreutils) nor \`stat -f '%Lp'\` (BSD/macOS) works here" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ "$ALL" -eq 1 ]]; then
|
||||
# Derives the plugin list from marketplace.json via the shared
|
||||
# list_marketplace_local_plugins helper (scripts/lib/marketplace-plugins.sh),
|
||||
# the same one scripts/check-manifests.sh uses, instead of hand-maintaining a
|
||||
# duplicate walk at every call site (see .pre-commit-config.yaml's
|
||||
# check-plugin-content-sync).
|
||||
REPO_ROOT="$(git rev-parse --show-toplevel 2>/dev/null || pwd)"
|
||||
#
|
||||
# Hard error rather than a `|| pwd` fallback, on scripts/sync-marketplace-mirror.sh's
|
||||
# reasoning: --all's entire work list hangs off REPO_ROOT, so a REPO_ROOT pointing at
|
||||
# something that is not this repo checks a plugin set that is not this repo's. Run
|
||||
# from outside a worktree the fallback happens to hit the `--all requires ...` error
|
||||
# below instead -- but only by accident, because $PWD had no marketplace.json in it;
|
||||
# $PWD holding an unrelated one is the case that would silently "pass".
|
||||
if ! REPO_ROOT="$(git rev-parse --show-toplevel 2>/dev/null)" || [[ -z "$REPO_ROOT" ]]; then
|
||||
echo "Error: not inside a git worktree -- cannot locate the repository root, and guessing \$PWD would let --all derive its plugin list from a marketplace.json that is not this repo's. Run this from within the repository." >&2
|
||||
exit 1
|
||||
fi
|
||||
MARKETPLACE="$REPO_ROOT/.claude-plugin/marketplace.json"
|
||||
if [[ ! -f "$MARKETPLACE" ]]; then
|
||||
echo "Error: --all requires $MARKETPLACE" >&2
|
||||
exit 1
|
||||
fi
|
||||
# Called from this shell, NOT from inside the `< <(...)` below -- that process
|
||||
# substitution is its own subshell, so a `set -e` abort or a jq parse failure in
|
||||
# there kills only the subshell and the `while read` loop simply gets no input.
|
||||
# An unparseable marketplace.json then reads exactly like "declares no plugins",
|
||||
# plugin_dirs comes back empty, batch_run dispatches nothing, and --check --all
|
||||
# exits 0 having verified nothing at all (see the floor below).
|
||||
assert_marketplace_manifest_usable "$MARKETPLACE"
|
||||
declare -a plugin_dirs=()
|
||||
while IFS=$'\t' read -r _name plugin_dir; do
|
||||
plugin_dirs+=("$plugin_dir")
|
||||
done < <(list_marketplace_local_plugins "$REPO_ROOT" "$MARKETPLACE")
|
||||
# Floor: --all is a gate whose work list comes from a GENERATED file, so an
|
||||
# empty derived set is drift, not a pass -- regenerating marketplace.json badly
|
||||
# would otherwise silence the very hook that guards it. assert_... above rejects
|
||||
# the malformed shapes; this rejects the well-formed-but-empty one.
|
||||
if [[ ${#plugin_dirs[@]} -eq 0 ]]; then
|
||||
echo "Error: $MARKETPLACE declares no local (string-source) plugin entries -- --all would check nothing and report success. Expected at least one; recompile it with \`apm pack\` if it is stale." >&2
|
||||
exit 1
|
||||
fi
|
||||
else
|
||||
declare -a plugin_dirs=("$@")
|
||||
fi
|
||||
@@ -165,9 +237,24 @@ fi
|
||||
# Fail fast on a basename collision rather than letting two plugin_dir arguments
|
||||
# silently share (and corrupt) the same $name.log/$name.status/$name.checkcopy
|
||||
# scratch paths below.
|
||||
#
|
||||
# The same loop rejects `.` and `..`, because every scratch path here is built by
|
||||
# pasting this basename onto $SCRATCH_ROOT. `basename ..` is `..`, so
|
||||
# "$SCRATCH_ROOT/$name" resolves to $SCRATCH_ROOT's PARENT -- apm pack then writes
|
||||
# its bundle into a directory this script neither owns nor cleans up (the EXIT
|
||||
# trap only removes $SCRATCH_ROOT itself), and sync_one's
|
||||
# `find "$scratch" -mindepth 1 -maxdepth 1 -type d | head -1` picks whatever
|
||||
# unrelated directory readdir happens to hand back first as the "bundle" -- whose
|
||||
# agents/ and skills/ a real sync then cp -a's into the plugin root, after an
|
||||
# rm -rf. The collision check below cannot catch this: a single `..` argument
|
||||
# collides with nothing.
|
||||
declare -a seen_names=()
|
||||
for plugin_dir in ${plugin_dirs[@]+"${plugin_dirs[@]}"}; do
|
||||
name="$(basename "${plugin_dir%/}")"
|
||||
if [[ "$name" == "." || "$name" == ".." ]]; then
|
||||
echo "Error: plugin dir '$plugin_dir' has basename '$name' -- scratch paths built from it would escape the scratch root. Pass the plugin directory by name, not by a relative traversal." >&2
|
||||
exit 1
|
||||
fi
|
||||
for seen in ${seen_names[@]+"${seen_names[@]}"}; do
|
||||
if [[ "$seen" == "$name" ]]; then
|
||||
echo "Error: duplicate plugin basename '$name' among arguments -- scratch paths would collide" >&2
|
||||
@@ -184,13 +271,62 @@ normalize_trailing_newline() {
|
||||
printf '%s\n' "$(cat "$1")" >"$2"
|
||||
}
|
||||
|
||||
# Reports every symlink under the .apm/ directories apm's exporter reads. Runs in
|
||||
# BOTH modes, and is the one check here that reads the .apm/ source tree rather than
|
||||
# comparing two bundle-derived trees.
|
||||
#
|
||||
# It has to: apm drops symlinks from the bundle silently (see the header), so by the
|
||||
# time either mode has a bundle the symlink is already gone from both sides of every
|
||||
# comparison. check_dir, check_file and check_path_modes all diff the live mirror
|
||||
# against a freshly synced copy -- neither side has the file, they agree, and --check
|
||||
# exits 0 while the author's content is simply not there. That is the entire failure
|
||||
# mode: not a mismatch, an absence with nothing left to mismatch against. Verified on
|
||||
# a fixture -- `ln -s real.md link.md` under .apm/skills/hello/ produced a mirror with
|
||||
# no link.md and a --check at exit 0.
|
||||
#
|
||||
# Reported rather than resolved (no dereference-and-copy): the mirror's contract is
|
||||
# that it is `apm pack`'s output, and materializing a file apm chose not to export
|
||||
# would make a real sync produce content the bundle does not contain -- exactly the
|
||||
# "reimplement the mapping outside apm" that ADR-0017 rejects. Telling the author is
|
||||
# the cheap, in-contract half.
|
||||
#
|
||||
# <name>/tests is carved out to match sync_dir's own depth-scoped exclusion: that
|
||||
# subtree is not mirrored whether or not it holds a symlink, so nothing is lost there.
|
||||
# The carve-out is on the SECOND path segment specifically, mirroring sync_dir's
|
||||
# `-mindepth 2 -maxdepth 2`; a `tests` deeper than that (assets/templates/tests) IS
|
||||
# mirrored, so a symlink in it is real loss and is reported.
|
||||
check_apm_symlinks() {
|
||||
local apm_dir="$1"
|
||||
local d src link rel rest
|
||||
|
||||
for d in "${APM_SOURCE_DIRS[@]}"; do
|
||||
src="$apm_dir/$d"
|
||||
[[ -d "$src" ]] || continue
|
||||
while IFS= read -r link; do
|
||||
rel="${link#"$src"/}"
|
||||
rest="${rel#*/}"
|
||||
if [[ "$rest" != "$rel" ]] && { [[ "$rest" == "tests" ]] || [[ "$rest" == tests/* ]]; }; then
|
||||
continue
|
||||
fi
|
||||
echo "FAIL $link: symlink under .apm/ -- apm's bundle exporter drops symlinks from the bundle entirely, so this content never reaches the mirror and no diff can see it missing. Replace it with a regular file." >&2
|
||||
FAIL=1
|
||||
done < <(find "$src" -type l -print 2>/dev/null | LC_ALL=C sort)
|
||||
done
|
||||
}
|
||||
|
||||
# Real-mode mirror write. There is no --check branch here on purpose: check mode
|
||||
# calls this same function against a throwaway copy of the plugin root and diffs
|
||||
# the result (see sync_one), so the tests/ exclusion below is the only copy of
|
||||
# that rule anywhere in this script.
|
||||
sync_dir() {
|
||||
local target_dir="$1" bundle_dir="$2" d="$3"
|
||||
local src="$bundle_dir/$d" dst="$target_dir/$d"
|
||||
# ${target_dir:?} for the same reason sync_hooks_json spells it out: `set -u`
|
||||
# aborts on an UNSET variable but not an empty one, and an empty $target_dir
|
||||
# would make the rm -rf calls below `rm -rf /agents`, `/skills`, `/commands`,
|
||||
# `/instructions`, `/extensions`. Unreachable from today's two call sites
|
||||
# (both pass either a `[[ -d ]]`-validated plugin_dir or a scratch path), but
|
||||
# the guard costs nothing and the next caller added here gets it for free.
|
||||
local src="$bundle_dir/$d" dst="${target_dir:?}/$d"
|
||||
|
||||
if [[ -d "$src" ]]; then
|
||||
rm -rf "$dst"
|
||||
@@ -232,8 +368,9 @@ sync_hooks_json() {
|
||||
#
|
||||
# ${target_dir:?} rather than a bare expansion: `set -u` aborts on an UNSET
|
||||
# variable but not an empty one, and an empty $target_dir would make this
|
||||
# `rm -rf /hooks`. The other rm -rf calls here take a $dst built by their
|
||||
# caller; this one is the only place a bare parameter is the whole prefix.
|
||||
# `rm -rf /hooks`. sync_dir above builds its own $dst from the same
|
||||
# caller-supplied parameter and carries the identical guard for the identical
|
||||
# reason -- neither function is special here.
|
||||
rm -rf "${target_dir:?}/$HOOKS_DIR_REL"
|
||||
|
||||
if [[ -f "$src" ]]; then
|
||||
@@ -287,28 +424,81 @@ check_file() {
|
||||
fi
|
||||
}
|
||||
|
||||
# Prints "<kind> <relative-path>" for every entry under the given relative paths.
|
||||
# `find` is used rather than a stat(1) call because stat's flags for mode
|
||||
# formatting are incompatible between GNU and BSD/macOS.
|
||||
# Relative paths whose mode the manifest below deliberately does NOT record. See
|
||||
# path_manifest's comment for the rule; this is the list of paths it applies to --
|
||||
# every file this pipeline WRITES rather than `cp -a`s.
|
||||
NO_MODE_PATHS=("$HOOKS_REL" "$LEGACY_HOOKS_REL")
|
||||
|
||||
# Prints "<kind> <octal-mode> <relative-path>" for every entry under the given
|
||||
# relative paths. `find` walks; $STAT_MODE_ARGS (probed once at startup) reads the
|
||||
# mode, because stat's flags for mode formatting are incompatible between GNU and
|
||||
# BSD/macOS.
|
||||
#
|
||||
# THE RULE: a mode is recorded for a path this pipeline COPIES, and not for one it
|
||||
# WRITES. The two sides of the comparison are a git checkout (actual) and a fresh
|
||||
# apm-pack-plus-mirror (expected), so a copied path's mode traces to the same
|
||||
# checkout on both sides and comparing it is meaningful; a written path's mode is
|
||||
# `0666 & ~umask` of whichever process wrote it -- the runtime umask on the expected
|
||||
# side, the umask of the checkout that produced the committed file on the actual
|
||||
# side. Those two are independent, git records neither, and no sync can make them
|
||||
# converge, so comparing them reports the runner's umask instead of a property of
|
||||
# the mirror.
|
||||
#
|
||||
# Full permission bits on COPIED FILES, not just the exec bit: an earlier revision
|
||||
# emitted a bare `exec`/`file` kind, so `chmod 444` on a mirrored SKILL.md left
|
||||
# --check at exit 0 while a real sync restored 644 -- check and sync disagreeing
|
||||
# again, in the same shape the exec-bit case already proved. Git tracks only the exec
|
||||
# bit, so this cannot arrive via a clone, but the gate's contract is that it agrees
|
||||
# with a real sync about everything a real sync writes.
|
||||
#
|
||||
# DIRECTORIES record no mode: nothing here sets one, they come from `mkdir -p` and
|
||||
# `cp -a`, and git tracks no directory mode. Verified concretely -- extracting this
|
||||
# repo with `git archive | tar -x` (which restores 0775/0664 when run as root) makes
|
||||
# a --check against the extracted tree report drift on every mirrored directory,
|
||||
# while the same check against the real 0755 tree is silent.
|
||||
#
|
||||
# $NO_MODE_PATHS record no mode for the identical reason, and this is where the
|
||||
# "copied files are immune because both sides trace to the same checkout" premise
|
||||
# stops holding. hooks/hooks.json is not copied: sync_hooks_json writes it with
|
||||
# `printf '%s\n' >`, at the RUNTIME umask. Widening the file comparison from the exec
|
||||
# bit to full permission bits therefore made the gate umask-dependent -- on a
|
||||
# umask-002 machine, `--check --all` over a umask-022 checkout reported
|
||||
# `< file 664 hooks/hooks.json` / `> file 644` for every plugin with hooks, and it
|
||||
# was not fixable by committing: a real sync writes 664, `git status` stays empty
|
||||
# because git tracks no non-exec mode, and the next --check from a umask-022 machine
|
||||
# fails in the opposite direction.
|
||||
#
|
||||
# The two generated plugin.json manifests are not in this manifest AT ALL -- see
|
||||
# sync_one's checked_paths for why listing them measured nothing.
|
||||
path_manifest() {
|
||||
local root="$1"
|
||||
shift
|
||||
local rel f kind
|
||||
local rel f kind mode no_mode
|
||||
for rel in "$@"; do
|
||||
if [[ ! -e "$root/$rel" ]] && [[ ! -L "$root/$rel" ]]; then
|
||||
continue
|
||||
fi
|
||||
find "$root/$rel" -print 2>/dev/null | LC_ALL=C sort | while IFS= read -r f; do
|
||||
if [[ -L "$f" ]]; then
|
||||
# A symlink's own lstat mode is 0777 on Linux and 0755 on macOS and is
|
||||
# not something either side controls -- the type difference is the whole
|
||||
# signal here, so record no mode for it.
|
||||
kind="symlink"
|
||||
mode="-"
|
||||
elif [[ -d "$f" ]]; then
|
||||
kind="dir"
|
||||
elif [[ -x "$f" ]]; then
|
||||
kind="exec"
|
||||
mode="-"
|
||||
else
|
||||
kind="file"
|
||||
mode="$(stat "${STAT_MODE_ARGS[@]}" "$f")"
|
||||
for no_mode in "${NO_MODE_PATHS[@]}"; do
|
||||
if [[ "${f#"$root"/}" == "$no_mode" ]]; then
|
||||
mode="-"
|
||||
break
|
||||
fi
|
||||
printf '%s %s\n' "$kind" "${f#"$root"/}"
|
||||
done
|
||||
fi
|
||||
printf '%s %s %s\n' "$kind" "$mode" "${f#"$root"/}"
|
||||
done || true
|
||||
done
|
||||
}
|
||||
@@ -317,7 +507,7 @@ path_manifest() {
|
||||
# entirely. So `chmod -x` on a mirrored script, or swapping a mirrored file for a
|
||||
# symlink to identical content, both leave --check at exit 0 while a real sync
|
||||
# silently repairs them -- check and sync disagreeing, which is the one thing this
|
||||
# gate exists to prevent. Compare an explicit type+exec-bit manifest as well.
|
||||
# gate exists to prevent. Compare an explicit type+permission manifest as well.
|
||||
#
|
||||
# The manifest is a full recursive listing, so it is also what makes an entry that
|
||||
# exists on only one side visible. That is the sole coverage the generated hooks/
|
||||
@@ -342,6 +532,25 @@ check_path_modes() {
|
||||
rm -f "$expected" "$actual"
|
||||
}
|
||||
|
||||
# Sets `mcpServers` on the generated Copilot manifest to the STRING ".mcp.json" --
|
||||
# the path form of the field, not the resolved server objects. Copilot's schema
|
||||
# types the field "string or object -- MCP server config path or inline
|
||||
# definitions" (plugins/kyberforge/docs/research/docs/github-copilot-plugins/
|
||||
# configuration.md), so both are valid there; only one of them is safe.
|
||||
#
|
||||
# An earlier revision inlined the objects with
|
||||
# `jq --slurpfile mcp '.mcpServers = $mcp[0].mcpServers'`. That copies .mcp.json
|
||||
# verbatim into a committed, marketplace-distributed file, bypassing apm's own
|
||||
# _sanitize_mcp_servers() (apm_cli/core/plugin_manifest.py), which drops
|
||||
# env/environment/headers/authorization and any key matching
|
||||
# token/secret/password/credential/apikey/key at any depth before writing the
|
||||
# Claude manifest. Proven with a fixture: an `env` block holding a token-shaped
|
||||
# value produced a sanitized .claude-plugin/plugin.json and a
|
||||
# .github/plugin/plugin.json carrying the live value. A path reference cannot
|
||||
# carry a secret at all -- the manifest names a file and the host resolves it at
|
||||
# load time -- and it preserves the ${VAR} indirection apm documents as the
|
||||
# posture for MCP secrets, rather than stripping it. See ADR-0017's 2026-08-14
|
||||
# amendment.
|
||||
reinject_mcp_servers() {
|
||||
local plugin_dir="$1" target_dir="$2"
|
||||
local mcp_src="$plugin_dir/.mcp.json" dst="$target_dir/.github/plugin/plugin.json"
|
||||
@@ -350,14 +559,31 @@ reinject_mcp_servers() {
|
||||
|
||||
# Match apm's own Claude-ecosystem plugin.json builder: mcpServers is omitted
|
||||
# entirely when the plugin declares none, not written out as an empty object.
|
||||
# A plugin whose .mcp.json is `{"mcpServers": {}}` gets no key at all -- not a
|
||||
# ".mcp.json" pointer at an empty file.
|
||||
local count
|
||||
count="$(jq '(.mcpServers // {}) | length' "$mcp_src")"
|
||||
[[ "$count" -gt 0 ]] || return 0
|
||||
|
||||
local tmp
|
||||
tmp="$(mktemp)"
|
||||
jq --slurpfile mcp "$mcp_src" '.mcpServers = $mcp[0].mcpServers' "$dst" >"$tmp"
|
||||
mv "$tmp" "$dst"
|
||||
jq '.mcpServers = ".mcp.json"' "$dst" >"$tmp"
|
||||
# Write THROUGH the existing file rather than `mv`-ing the mktemp over it:
|
||||
# mktemp creates 0600, and mv carries that mode onto a tracked, published
|
||||
# manifest. Git records only the exec bit, so the demotion survived every
|
||||
# commit and review unnoticed -- plugins/bin/.github/plugin/plugin.json really
|
||||
# was 0600 on disk while its five siblings were 0644. Redirecting into $dst
|
||||
# keeps its inode, owner and mode, which is the whole fix: whatever mode apm
|
||||
# pack gave the manifest a moment ago is exactly the mode it still has.
|
||||
#
|
||||
# Deliberately NO `chmod 644` after it. A hardcoded mode here does not pin
|
||||
# anything a re-sync could converge on -- apm pack created $dst at the runtime
|
||||
# umask, and the committed file carries the umask of the checkout that produced
|
||||
# it -- it only makes those two disagree. It did: on a umask-002 checkout,
|
||||
# --check reported `< file 644 .github/plugin/plugin.json` / `> file 664` with
|
||||
# nothing wrong. See path_manifest's $NO_MODE_PATHS comment for the rule.
|
||||
cat "$tmp" >"$dst"
|
||||
rm -f "$tmp"
|
||||
}
|
||||
|
||||
# --check-only: diffs a freshly-regenerated manifest file (in the throwaway
|
||||
@@ -368,6 +594,18 @@ sync_plugin_manifest() {
|
||||
local plugin_dir="$1" pack_cwd="$2" rel="$3"
|
||||
local src="$pack_cwd/$rel" dst="$plugin_dir/$rel"
|
||||
|
||||
# A manifest that is a symlink is not a cosmetic difference: apm pack opens it
|
||||
# for writing and reinject_mcp_servers redirects into it, and both follow the
|
||||
# link -- so a real sync silently rewrites whatever it points at instead of the
|
||||
# manifest. It has to be asserted against the real plugin root like this, not via
|
||||
# check_path_modes: that compares against a `cp -a` of this same root, which
|
||||
# reproduces the symlink on the expected side and reports the two as equal.
|
||||
if [[ -L "$dst" ]]; then
|
||||
echo "DRIFT $dst: is a symlink -- apm pack and the mcpServers re-injection both write THROUGH it, so a real sync would overwrite its target instead of the manifest. Replace it with a regular file." >&2
|
||||
FAIL=1
|
||||
return 0
|
||||
fi
|
||||
|
||||
if [[ -f "$src" ]]; then
|
||||
if [[ ! -f "$dst" ]]; then
|
||||
echo "DRIFT $dst: missing (would be created by apm pack from apm.yml/.mcp.json)" >&2
|
||||
@@ -388,9 +626,16 @@ sync_plugin_manifest() {
|
||||
# FAIL here is that subshell's own copy -- it never touches the parent's FAIL
|
||||
# and must be handed back via status_file instead.
|
||||
sync_one() {
|
||||
local plugin_dir="${1%/}" status_file="$2"
|
||||
local plugin_dir="${1%/}" status_file="$2" verified_file="$3"
|
||||
local apm_dir="$plugin_dir/.apm"
|
||||
FAIL=0
|
||||
# "This plugin's mirror was actually synced/checked." Flipped to 1 only at the
|
||||
# very bottom, so every early return below -- nonexistent directory, no .apm/,
|
||||
# apm pack failure, no bundle -- leaves it 0. --all compares the count of these
|
||||
# against the number of plugins marketplace.json declared (see the dispatch
|
||||
# loop's aggregation); an exit-status-only handshake cannot express "ran, but
|
||||
# verified nothing", which is exactly what the SKIP below is.
|
||||
echo 0 >"$verified_file"
|
||||
|
||||
if [[ ! -d "$plugin_dir" ]]; then
|
||||
echo "FAIL $plugin_dir: plugin directory does not exist" >&2
|
||||
@@ -405,6 +650,10 @@ sync_one() {
|
||||
return 0
|
||||
fi
|
||||
|
||||
# Before the pack, not after: this reads the .apm/ source tree, and it is the
|
||||
# only report that survives apm's silent symlink filtering (see the header).
|
||||
check_apm_symlinks "$apm_dir"
|
||||
|
||||
local name scratch bundle_dir pack_log pack_cwd
|
||||
name="$(basename "$plugin_dir")"
|
||||
scratch="$SCRATCH_ROOT/$name"
|
||||
@@ -465,6 +714,21 @@ sync_one() {
|
||||
# $HOOKS_DIR_REL, not $HOOKS_REL: the manifest comparison has to see the whole
|
||||
# generated directory (see check_path_modes), and listing it recursively already
|
||||
# covers hooks/hooks.json.
|
||||
#
|
||||
# Neither generated plugin.json is listed here, and adding one back measures
|
||||
# nothing on any of the three axes this manifest compares. Mode: in check mode
|
||||
# the expected side is the seeded pack_cwd COPY of the real plugin root, where
|
||||
# apm pack rewrites a file that is already there and open-for-write preserves
|
||||
# the existing inode's mode -- so the expected mode is inherited from the actual
|
||||
# mode by construction. Verified: `chmod 600 plugins/bin/.claude-plugin/
|
||||
# plugin.json` left --check at exit 0 the entire time that entry was listed.
|
||||
# Type: a symlinked manifest survives the same `cp -a` as a symlink and apm pack
|
||||
# writes straight through it, so both sides record `symlink` -- also verified at
|
||||
# exit 0. Presence: sync_plugin_manifest already reports both directions, with a
|
||||
# message naming apm.yml as the thing to fix. A symlinked manifest IS a real
|
||||
# hazard (apm pack and reinject_mcp_servers both write through it, corrupting
|
||||
# whatever it points at), so it is asserted where it can actually be seen --
|
||||
# against the real plugin root, in sync_plugin_manifest.
|
||||
checked_paths=("${MIRROR_DIRS[@]}" "$HOOKS_DIR_REL" "$LEGACY_HOOKS_REL")
|
||||
for d in "${MIRROR_DIRS[@]}"; do
|
||||
check_dir "$plugin_dir" "$pack_cwd" "$d"
|
||||
@@ -476,6 +740,7 @@ sync_one() {
|
||||
sync_plugin_manifest "$plugin_dir" "$pack_cwd" ".claude-plugin/plugin.json"
|
||||
sync_plugin_manifest "$plugin_dir" "$pack_cwd" ".github/plugin/plugin.json"
|
||||
fi
|
||||
echo 1 >"$verified_file"
|
||||
echo "$FAIL" >"$status_file"
|
||||
}
|
||||
|
||||
@@ -490,18 +755,50 @@ sync_one() {
|
||||
declare -a batch_args=()
|
||||
for plugin_dir in ${plugin_dirs[@]+"${plugin_dirs[@]}"}; do
|
||||
name="$(basename "${plugin_dir%/}")"
|
||||
cmd="$(printf 'sync_one %q %q' "$plugin_dir" "$SCRATCH_ROOT/$name.status")"
|
||||
cmd="$(printf 'sync_one %q %q %q' "$plugin_dir" "$SCRATCH_ROOT/$name.status" \
|
||||
"$SCRATCH_ROOT/$name.verified")"
|
||||
batch_args+=("$name" "$cmd")
|
||||
done
|
||||
batch_run "$SCRATCH_ROOT" ${batch_args[@]+"${batch_args[@]}"}
|
||||
|
||||
declare -a unverified=()
|
||||
for plugin_dir in ${plugin_dirs[@]+"${plugin_dirs[@]}"}; do
|
||||
name="$(basename "${plugin_dir%/}")"
|
||||
cat "$SCRATCH_ROOT/$name.log" >&2
|
||||
status="$(cat "$SCRATCH_ROOT/$name.status" 2>/dev/null || echo 1)"
|
||||
[[ "$status" -ne 0 ]] && FAIL=1
|
||||
# Missing file reads as 0 (unverified), matching the status file's `|| echo 1`
|
||||
# default: a job whose marker never got written did not verify anything.
|
||||
if [[ "$(cat "$SCRATCH_ROOT/$name.verified" 2>/dev/null || echo 0)" != "1" ]]; then
|
||||
unverified+=("$plugin_dir")
|
||||
fi
|
||||
done
|
||||
|
||||
# --all's second floor, and the one the zero-plugin floor above cannot express.
|
||||
# That floor rejects "the marketplace yielded no plugins"; this rejects "the
|
||||
# marketplace yielded N and only M were actually verified". The gap between them
|
||||
# is sync_one's SKIP path: a plugin directory with no .apm/ reports status 0 and
|
||||
# checks nothing, so --all printed one SKIP line among the noise and exited 0
|
||||
# having verified fewer plugins than it listed. --all is a pre-push gate over a
|
||||
# GENERATED work list, so "checked fewer than declared" has to be a failure.
|
||||
#
|
||||
# There is no legitimate state in this repo where a listed local plugin lacks
|
||||
# .apm/: ADR-0015 made .apm/ the sole authoring source for every plugin here, and
|
||||
# ADR-0017's mirror is defined as that directory's compiled output, so a local
|
||||
# marketplace entry without one is drift in one of the two -- either the directory
|
||||
# lost its .apm/, or marketplace.json still lists a package that is no longer one.
|
||||
# Both need a human, and neither is fixed by re-running the sync, so this is
|
||||
# reported separately from the drift hint below rather than folded into it.
|
||||
#
|
||||
# SKIP stays a skip when plugin directories are named EXPLICITLY on the command
|
||||
# line: there the caller chose the work list and a non-apm directory is their
|
||||
# business, not a generated file's drift.
|
||||
UNVERIFIED=0
|
||||
if [[ "$ALL" -eq 1 ]] && [[ ${#unverified[@]} -gt 0 ]]; then
|
||||
echo "Error: --all verified $(( ${#plugin_dirs[@]} - ${#unverified[@]} )) of the ${#plugin_dirs[@]} local plugin entries $MARKETPLACE declares; unverified: ${unverified[*]}. A listed plugin that cannot be checked (typically: its .apm/ is gone, which sync_one skips) is drift, not a pass -- restore its .apm/, or drop the entry from root apm.yml's marketplace.packages[] and recompile." >&2
|
||||
UNVERIFIED=1
|
||||
fi
|
||||
|
||||
if [[ "$FAIL" -ne 0 ]]; then
|
||||
if [[ "$CHECK" -eq 1 ]]; then
|
||||
if [[ "$ALL" -eq 1 ]]; then
|
||||
@@ -512,3 +809,5 @@ if [[ "$FAIL" -ne 0 ]]; then
|
||||
fi
|
||||
exit 1
|
||||
fi
|
||||
|
||||
[[ "$UNVERIFIED" -eq 0 ]] || exit 1
|
||||
+84
-5
@@ -32,9 +32,75 @@ done < <(
|
||||
| sort
|
||||
)
|
||||
|
||||
# The expected set of files is DERIVED from the index, not guessed at with a
|
||||
# hardcoded floor. This was `BATS_FILE_FLOOR=8` against a real count of 10, and
|
||||
# two files of slack is not a hypothetical margin -- deleting two .bats files
|
||||
# (agentsmd-audit/tests/ alone holds three) left the run reporting
|
||||
# "155 tests, 0 failures" and exiting 0 with 11 tests silently gone.
|
||||
#
|
||||
# `git ls-files` gives the exact set for free. It catches a *removal* (a tracked
|
||||
# file gone from the worktree) and an *addition* the walk above missed (a tracked
|
||||
# file the `find` exclusions or a moved search root no longer reach), it needs no
|
||||
# magic number, and it needs no edit when a plugin is added or removed -- a newly
|
||||
# `git add`ed .bats file joins the expectation immediately, where a floor only
|
||||
# ever grows more slack as the suite grows.
|
||||
#
|
||||
# Direction matters: every tracked file must have been discovered, but a
|
||||
# discovered file need NOT be tracked. An untracked, not-yet-committed .bats file
|
||||
# is ordinary work in progress, and a file removed deliberately with `git rm` (or
|
||||
# a staged deletion) leaves the index, so an intentional removal passes while an
|
||||
# accidental disappearance fails. The same `-not -path` exclusions are reapplied
|
||||
# to the index listing so the two sides are compared over the same universe.
|
||||
#
|
||||
# The exact-equality check on `--show-toplevel` is what keeps this off the
|
||||
# fixture repos in tests/test-run-bats.sh and tests/test-run-tests.sh: those are
|
||||
# mktemp trees holding one or two .bats files by design, and git resolves no
|
||||
# worktree for them. That degradation is announced rather than silent, and the
|
||||
# zero-file check below is unconditional, so a non-git checkout still cannot run
|
||||
# on an empty set.
|
||||
EXPECTED_FILES=()
|
||||
DERIVED=false
|
||||
GIT_TOPLEVEL="$(git -C "$REPO_ROOT" rev-parse --show-toplevel 2>/dev/null || true)"
|
||||
if [[ -n "$GIT_TOPLEVEL" && "$GIT_TOPLEVEL" == "$REPO_ROOT" ]]; then
|
||||
DERIVED=true
|
||||
while IFS= read -r f; do
|
||||
[[ -n "$f" ]] && EXPECTED_FILES+=("$REPO_ROOT/$f")
|
||||
done < <(
|
||||
git -C "$REPO_ROOT" ls-files -- '*.bats' \
|
||||
| grep -Ev '(^|/)tests/bats/|(^|/)test_helper/|(^|/)\.claude/worktrees/' \
|
||||
| sort || true
|
||||
)
|
||||
else
|
||||
echo "Note: $REPO_ROOT is not a git worktree root, so the expected .bats file set could not be derived from the index — only the zero-file check below applies" >&2
|
||||
fi
|
||||
|
||||
if [[ "$DERIVED" == true && ${#EXPECTED_FILES[@]} -gt 0 ]]; then
|
||||
MISSING=()
|
||||
for expected in ${EXPECTED_FILES[@]+"${EXPECTED_FILES[@]}"}; do
|
||||
found=false
|
||||
for actual in ${TEST_FILES[@]+"${TEST_FILES[@]}"}; do
|
||||
if [[ "$actual" == "$expected" ]]; then
|
||||
found=true
|
||||
break
|
||||
fi
|
||||
done
|
||||
[[ "$found" == true ]] || MISSING+=("${expected#"$REPO_ROOT"/}")
|
||||
done
|
||||
if [[ ${#MISSING[@]} -gt 0 ]]; then
|
||||
echo "Error: ${#MISSING[@]} of ${#EXPECTED_FILES[@]} tracked .bats file(s) were not discovered under $REPO_ROOT — they were deleted without being removed from the index, or the search path/exclusions above no longer reach them:" >&2
|
||||
for m in ${MISSING[@]+"${MISSING[@]}"}; do
|
||||
echo " $m" >&2
|
||||
done
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
# Unconditional, and separate from the derived check above: a tree with nothing
|
||||
# tracked (a tarball export, a fresh scaffold) still must not run on an empty set
|
||||
# and call it green. This was `exit 0` with a note on stderr nobody reads.
|
||||
if [[ ${#TEST_FILES[@]} -eq 0 ]]; then
|
||||
echo "No .bats test files found." >&2
|
||||
exit 0
|
||||
echo "Error: found 0 .bats file(s) under $REPO_ROOT — the search path is wrong or the suite has been gutted" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Each file gets its own `bats` process, run concurrently (bounded by core
|
||||
@@ -83,17 +149,30 @@ for f in ${TEST_FILES[@]+"${TEST_FILES[@]}"}; do
|
||||
# file bats really did run, so it has to be distinguishable from a file that
|
||||
# produced nothing whatsoever.
|
||||
file_plan="$(grep -c '^1\.\.[0-9]' "$SCRATCH_ROOT/$i.log" || true)"
|
||||
# String-compared below, not `-ne`. `-ne` is arithmetic and bash evaluates an
|
||||
# empty string as 0 there -- `[[ "" -ne 0 ]]` is false -- so an *empty* status
|
||||
# file read as a clean exit. The `|| echo 1` fallback only covers a *missing*
|
||||
# file; an existing-but-empty one is what a job killed between the `>` and the
|
||||
# `echo` leaves behind, or what ENOSPC leaves behind.
|
||||
status="$(cat "$SCRATCH_ROOT/$i.status" 2>/dev/null || echo 1)"
|
||||
TOTAL_OK=$((TOTAL_OK + file_ok))
|
||||
TOTAL_NOT_OK=$((TOTAL_NOT_OK + file_not_ok))
|
||||
TOTAL_PLANS=$((TOTAL_PLANS + file_plan))
|
||||
if [[ "$file_not_ok" -gt 0 || "$status" -ne 0 ]]; then
|
||||
# Two independent failure signals, deliberately OR-ed: a file can report `not
|
||||
# ok` lines while its process still exits 0 (a bats formatter or wrapper that
|
||||
# swallows the status), and a file can exit non-zero having emitted no `not
|
||||
# ok` at all (a crash, a timeout, an unbound variable in setup_file). Real
|
||||
# bats normally emits both at once, so each signal masks the other and
|
||||
# dropping either half is invisible without tests that produce one without
|
||||
# the other -- tests/test-run-bats.sh has those.
|
||||
if [[ "$file_not_ok" -gt 0 || "$status" != "0" ]]; then
|
||||
FAIL=1
|
||||
fi
|
||||
done
|
||||
|
||||
# Zero counted tests is never a clean run: files were found (the empty-TEST_FILES
|
||||
# case exits above), so nothing was executed. Without this, a `bats` that emits
|
||||
# Zero counted tests is never a clean run: files were found (zero discovered
|
||||
# files, and any tracked file that went missing, exit non-zero above), so nothing
|
||||
# was executed. Without this, a `bats` that emits
|
||||
# nothing and exits 0 -- a broken binary, a formatter change, or a wholesale
|
||||
# `@test` removal -- reports "0 tests, 0 failures" and exits green, silently
|
||||
# turning a total harness failure into a pass.
|
||||
|
||||
+143
-8
@@ -1,30 +1,110 @@
|
||||
#!/usr/bin/env bash
|
||||
# Run all test-*.sh files in the repo (including plugins) and the bats suite.
|
||||
# Usage: bash tests/run-tests.sh [--bats-only]
|
||||
# Usage: bash tests/run-tests.sh [--bats-only] [--strict]
|
||||
#
|
||||
# A script exiting 77 (the automake convention) is reported as SKIPPED, not
|
||||
# passed — a suite that can't run for lack of a binary must not read as green.
|
||||
#
|
||||
# --strict (or RUN_TESTS_STRICT=1) additionally makes any skip FAIL the run. Two
|
||||
# different readings of a skip are both correct, and which one applies depends on
|
||||
# who is running:
|
||||
#
|
||||
# * ad-hoc, on a laptop: skipping gracefully is the point. You are missing a
|
||||
# dev binary, the other 15 suites still tell you something, and turning that
|
||||
# into a red run would just train people to ignore red.
|
||||
# * as a GATE (the run-tests pre-push hook): a skip is a SETUP ERROR, not a
|
||||
# legitimate state. AGENTS.md documents vale, apm and jq as required pre-push
|
||||
# dependencies, so a suite that cannot run on the machine doing the pushing
|
||||
# means the machine is misconfigured -- and pre-commit prints NOTHING for a
|
||||
# passing hook, so the skip list below is swallowed entirely. On a vale-less
|
||||
# PATH that silently shipped a green gate having verified 15 of 17 suites.
|
||||
# Exactly the vacuous-pass class the rest of this file exists to close.
|
||||
#
|
||||
# Deliberately its own switch, NOT folded into
|
||||
# CHECK_VALE_STYLE_SYNC_ALLOW_MISSING_VALE. That one governs whether
|
||||
# check-vale-style-sync may downgrade itself; this one governs whether the test
|
||||
# dispatcher tolerates an unrunnable suite. They are separate decisions and one
|
||||
# flag disarming both gates is how an opt-out quietly grows blast radius.
|
||||
#
|
||||
# TEST_DIR — override root to search for test-*.sh (default: REPO_ROOT); used by tests.
|
||||
set -euo pipefail
|
||||
|
||||
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
BATS="$REPO_ROOT/tests/run-bats.sh"
|
||||
BATS_ONLY=false
|
||||
[[ "${1:-}" == "--bats-only" ]] && BATS_ONLY=true
|
||||
STRICT=false
|
||||
if [[ "${RUN_TESTS_STRICT:-}" == "1" ]]; then
|
||||
STRICT=true
|
||||
fi
|
||||
# A loop rather than the `[[ "${1:-}" == --bats-only ]]` test this used to be, so
|
||||
# the two flags compose and an unknown flag is rejected instead of ignored. A
|
||||
# silently-ignored `--strict` is the one typo that would turn the gate back off.
|
||||
for arg in ${@+"$@"}; do
|
||||
case "$arg" in
|
||||
--bats-only) BATS_ONLY=true ;;
|
||||
--strict) STRICT=true ;;
|
||||
*)
|
||||
echo "Usage: $0 [--bats-only] [--strict]" >&2
|
||||
exit 2
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
SEARCH_ROOT="${TEST_DIR:-$REPO_ROOT}"
|
||||
|
||||
FAILED=()
|
||||
SKIPPED=()
|
||||
# Parallel array, index-matched to SKIPPED. Not an associative array: bash 3.2
|
||||
# (macOS) has none, and tests/test-vale-wrap.sh's bash-3.2 scan rejects
|
||||
# `declare -A` outright.
|
||||
SKIP_REASONS=()
|
||||
PASSED=0
|
||||
SKIP_EXIT=77
|
||||
|
||||
# A missing or non-executable run-bats.sh is a hard error, never a silent skip.
|
||||
# This was `if [[ -x "$BATS" ]]; then ... fi` with no else and no assertion that
|
||||
# bats ran at all, so renaming, moving, or dropping the executable bit off
|
||||
# run-bats.sh made the entire bats suite vanish with zero diagnostic and the run
|
||||
# still printed "Summary: N passed, 0 failed" and exited 0 -- and --bats-only
|
||||
# degraded to a no-op that printed nothing and exited 0. That is the same
|
||||
# green-either-way hole run-bats.sh's own zero-count guard closes one level down;
|
||||
# this closes it in the dispatcher that pre-push actually invokes.
|
||||
#
|
||||
# Present and executable is still not "it ran". `bash "$BATS"` on an EMPTY
|
||||
# run-bats.sh exits 0 having printed nothing, and the dispatcher printed
|
||||
# `=== bats ===`, a blank line, and a green summary -- the same green-either-way
|
||||
# defect one spelling over. Truncation, a partial write, an editor saving an
|
||||
# empty buffer, and a `set -e` abort in a future run-bats.sh preamble all land
|
||||
# there. So the runner's own summary line is required, and its count must be
|
||||
# non-zero: that line is run-bats.sh's contract with this script, and it is only
|
||||
# emitted after run-bats.sh's own zero-count guard has passed.
|
||||
#
|
||||
# Stdout is captured (the summary is on stdout) while stderr passes straight
|
||||
# through, so a failing runner's diagnostics still reach the terminal live. The
|
||||
# capture costs no streaming that was not already lost: run-bats.sh buffers its
|
||||
# per-file output and flushes it at the end regardless.
|
||||
run_bats() {
|
||||
if [[ -x "$BATS" ]]; then
|
||||
if [[ ! -x "$BATS" ]]; then
|
||||
echo "Error: bats runner not found or not executable at $BATS — the bats suite cannot be skipped silently" >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "=== bats ==="
|
||||
bash "$BATS"
|
||||
local out rc=0 summary count
|
||||
out="$(bash "$BATS")" || rc=$?
|
||||
[[ -z "$out" ]] || printf '%s\n' "$out"
|
||||
echo ""
|
||||
if [[ $rc -ne 0 ]]; then
|
||||
exit "$rc"
|
||||
fi
|
||||
summary="$(printf '%s\n' "$out" | grep -E '^[0-9]+ tests, [0-9]+ failures$' | tail -n 1 || true)"
|
||||
if [[ -z "$summary" ]]; then
|
||||
echo "Error: $BATS exited 0 without reporting an 'N tests, M failures' summary — it ran but produced nothing, so the bats suite was not verified" >&2
|
||||
exit 1
|
||||
fi
|
||||
count="${summary%% *}"
|
||||
if [[ "$count" -eq 0 ]]; then
|
||||
echo "Error: $BATS reported 0 tests — the bats suite executed nothing" >&2
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
@@ -89,10 +169,39 @@ for script in ${SCRIPTS[@]+"${SCRIPTS[@]}"}; do
|
||||
echo "=== $rel ==="
|
||||
cat "$SCRATCH_ROOT/$idx.log"
|
||||
rc="$(cat "$SCRATCH_ROOT/$idx.status" 2>/dev/null || echo 1)"
|
||||
if [[ $rc -eq 0 ]]; then
|
||||
# String comparison, not `-eq`. `-eq` is arithmetic, and bash evaluates an
|
||||
# empty string as 0 there -- `[[ "" -eq 0 ]]` is true -- so an *empty* status
|
||||
# file counted as a pass. The `|| echo 1` fallback above only covers a
|
||||
# *missing* file; a file that exists but is empty is what you get when the job
|
||||
# is killed between the `>` truncating it and the `echo` completing, or on
|
||||
# ENOSPC. Under `==` an empty status falls through to FAILED, which is the only
|
||||
# safe reading of "the job did not report a result".
|
||||
if [[ "$rc" == "0" ]]; then
|
||||
PASSED=$((PASSED + 1))
|
||||
elif [[ $rc -eq $SKIP_EXIT ]]; then
|
||||
elif [[ "$rc" == "$SKIP_EXIT" ]]; then
|
||||
SKIPPED+=("$rel")
|
||||
# Capture WHY, not just that. The reason is printed by the suite itself and
|
||||
# is otherwise swallowed with the rest of its log, which leaves the reader
|
||||
# knowing something was skipped but not which binary to install. There is no
|
||||
# single house format for it -- three suites print `SKIP: <reason>` on stdout
|
||||
# and one prints `apm not installed -- skipping (...)` on stderr -- so this
|
||||
# tries the shapes in decreasing order of confidence and falls back to the
|
||||
# last thing the suite said before exiting 77, which for a guard that exits
|
||||
# immediately is the reason by construction. batch-run.sh folds stderr into
|
||||
# the same log, so the stderr spelling is reachable here.
|
||||
reason="$(grep -E '^[[:space:]]*SKIP' "$SCRATCH_ROOT/$idx.log" 2>/dev/null | head -n 1 || true)"
|
||||
if [[ -z "$reason" ]]; then
|
||||
reason="$(grep -iE 'skip' "$SCRATCH_ROOT/$idx.log" 2>/dev/null | head -n 1 || true)"
|
||||
fi
|
||||
if [[ -z "$reason" ]]; then
|
||||
reason="$(grep -vE '^[[:space:]]*$' "$SCRATCH_ROOT/$idx.log" 2>/dev/null | tail -n 1 || true)"
|
||||
fi
|
||||
if [[ -z "$reason" ]]; then
|
||||
reason="(exited $SKIP_EXIT without printing a reason)"
|
||||
fi
|
||||
# Trimmed of leading whitespace so the reasons line up under their suite
|
||||
# names regardless of how each suite indents its own message.
|
||||
SKIP_REASONS+=("${reason#"${reason%%[![:space:]]*}"}")
|
||||
else
|
||||
FAILED+=("$rel")
|
||||
fi
|
||||
@@ -100,16 +209,42 @@ for script in ${SCRIPTS[@]+"${SCRIPTS[@]}"}; do
|
||||
done
|
||||
|
||||
echo "=== Summary: $PASSED passed, ${#SKIPPED[@]} skipped, ${#FAILED[@]} failed ==="
|
||||
if [[ ${#SKIPPED[@]} -gt 0 ]]; then
|
||||
# Suppressed under --strict: the strict block below reports the same suites with
|
||||
# the same reasons, and printing both left the reader scrolling past one list to
|
||||
# reach an identical one. Under strict the failure block IS the list.
|
||||
if [[ ${#SKIPPED[@]} -gt 0 && "$STRICT" != true ]]; then
|
||||
echo "Skipped scripts:"
|
||||
sidx=0
|
||||
for s in ${SKIPPED[@]+"${SKIPPED[@]}"}; do
|
||||
echo " $s"
|
||||
echo " ${SKIP_REASONS[$sidx]}"
|
||||
sidx=$((sidx + 1))
|
||||
done
|
||||
fi
|
||||
|
||||
RC=0
|
||||
if [[ ${#FAILED[@]} -gt 0 ]]; then
|
||||
echo "Failed scripts:"
|
||||
for s in ${FAILED[@]+"${FAILED[@]}"}; do
|
||||
echo " $s"
|
||||
done
|
||||
exit 1
|
||||
RC=1
|
||||
fi
|
||||
|
||||
# Strict mode turns every skip into a failure. Reported separately from FAILED
|
||||
# above rather than folded into it: a skipped suite did not fail, the machine
|
||||
# did, and a message that says so points at the fix. Named with reasons again
|
||||
# here (not just referenced) because this block goes to stderr and is what a
|
||||
# pre-push reader actually gets handed.
|
||||
if [[ "$STRICT" == true && ${#SKIPPED[@]} -gt 0 ]]; then
|
||||
echo "Error: --strict and ${#SKIPPED[@]} suite(s) skipped. Run as a gate, a skip is a SETUP ERROR on this machine, not a legitimate state: AGENTS.md documents vale, apm and jq as required pre-push dependencies, so every suite is expected to be runnable here. Install what each suite names below and re-run; do not skip the hook." >&2
|
||||
sidx=0
|
||||
for s in ${SKIPPED[@]+"${SKIPPED[@]}"}; do
|
||||
echo " $s" >&2
|
||||
echo " ${SKIP_REASONS[$sidx]}" >&2
|
||||
sidx=$((sidx + 1))
|
||||
done
|
||||
RC=1
|
||||
fi
|
||||
|
||||
exit "$RC"
|
||||
Executable
+264
@@ -0,0 +1,264 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
# Tests for scripts/check-apm-agents-valid.sh — the gate that runs agent-audit's
|
||||
# validate.sh over the repo's REAL plugin-scope agent files.
|
||||
#
|
||||
# Case 1 runs against the real repo. Every other case runs against a synthetic
|
||||
# fixture, for the same reason scripts/check-scope-walkup-sync.sh's tests do: the
|
||||
# RED cases have to mutate an agent file, and mutating the real tree from a test
|
||||
# is not on.
|
||||
#
|
||||
# The point of case 1b is that case 1's exit 0 is EARNED. Exit 0 is also what
|
||||
# this script would print if it validated nothing at all, which is the exact
|
||||
# defect it exists to close — so the clean run's own count is asserted against
|
||||
# the index rather than taken on trust.
|
||||
|
||||
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
SCRIPT="$REPO_ROOT/scripts/check-apm-agents-valid.sh"
|
||||
PASS=0
|
||||
FAIL=0
|
||||
|
||||
pass() { echo " PASS: $1"; PASS=$((PASS + 1)); }
|
||||
fail() { echo " FAIL: $1"; FAIL=$((FAIL + 1)); }
|
||||
|
||||
# validate.sh is a python3 program. Without python3 the script under test fails
|
||||
# closed by design, which is correct behavior but makes every case here assert
|
||||
# the same missing-dependency message instead of what it is meant to assert.
|
||||
if ! command -v python3 >/dev/null 2>&1; then
|
||||
echo "SKIP: python3 is not installed — agent-audit's validate.sh cannot run, so these cases would only re-assert the missing-dependency guard"
|
||||
exit 77
|
||||
fi
|
||||
|
||||
FIXTURES=()
|
||||
cleanup() { [[ ${#FIXTURES[@]} -eq 0 ]] || rm -rf "${FIXTURES[@]}"; }
|
||||
trap cleanup EXIT
|
||||
|
||||
# Per-run scratch dir for captured output. tests/run-tests.sh fans test scripts
|
||||
# out concurrently, so a fixed path under the shared system temp directory is
|
||||
# mutable state shared between two simultaneous runs.
|
||||
RUN_TMP="$(mktemp -d)"
|
||||
FIXTURES+=("$RUN_TMP")
|
||||
|
||||
# Builds a minimal REPO_ROOT: agent-audit's validator and the field inventory it
|
||||
# reads at load time, plus one plugin carrying a valid agent file. The plugin's
|
||||
# apm.yml needs a top-level `type:` line — that is the marker validate.sh's
|
||||
# walk-up uses to resolve plugin scope, and without it the fixture would resolve
|
||||
# to project scope and fail looking for a .github/agents counterpart.
|
||||
#
|
||||
# `pwd -P` because the script under test compares its REPO_ROOT against
|
||||
# `git rev-parse --show-toplevel`, which is always physical. On a platform where
|
||||
# the system temp dir is a symlink (macOS /tmp -> /private/tmp) a logical path
|
||||
# would silently fail that equality and take the un-derived branch, quietly
|
||||
# turning case 4 into a no-op.
|
||||
make_fixture() {
|
||||
local dir
|
||||
dir="$(cd "$(mktemp -d)" && pwd -P)"
|
||||
local aa="$dir/plugins/kyberforge/.apm/skills/agent-audit"
|
||||
mkdir -p "$aa/scripts" "$aa/references" "$dir/plugins/lint/.apm/agents"
|
||||
cp "$REPO_ROOT/plugins/kyberforge/.apm/skills/agent-audit/scripts/validate.sh" "$aa/scripts/"
|
||||
cp "$REPO_ROOT/plugins/kyberforge/.apm/skills/agent-audit/references/field-inventory.md" "$aa/references/"
|
||||
cat > "$dir/plugins/lint/apm.yml" <<'YAML'
|
||||
name: lint
|
||||
version: 0.0.1
|
||||
type: hybrid
|
||||
YAML
|
||||
cat > "$dir/plugins/lint/.apm/agents/lint-runner.agent.md" <<'MD'
|
||||
---
|
||||
name: lint-runner
|
||||
description: Runs a linter sweep over a target scope and reports findings back to the caller.
|
||||
---
|
||||
|
||||
Run the linter over the scope the caller names and report what it found.
|
||||
MD
|
||||
echo "$dir"
|
||||
}
|
||||
|
||||
# --- 1. Exits 0 against this repo's real agent files ---
|
||||
echo ""
|
||||
echo "--- exits 0 against this repo's real agent files ---"
|
||||
if bash "$SCRIPT" "$REPO_ROOT" > "$RUN_TMP/clean.out" 2>&1; then
|
||||
pass "exits 0 against this repo's four real plugin-scope agent files"
|
||||
else
|
||||
fail "exited non-zero against this repo's real (already-fixed) agent files"
|
||||
sed 's/^/ /' "$RUN_TMP/clean.out"
|
||||
fi
|
||||
|
||||
# --- 1b. That exit 0 was earned: the count matches the index, and is non-zero ---
|
||||
echo ""
|
||||
echo "--- the clean run's reported count matches git ls-files ---"
|
||||
TRACKED_COUNT="$(git -C "$REPO_ROOT" ls-files -- 'plugins/*/.apm/agents/*.agent.md' | grep -c . || true)"
|
||||
REPORTED_COUNT="$(sed -n 's/^APM agent validation passed: \([0-9]\{1,\}\) plugin-scope.*/\1/p' "$RUN_TMP/clean.out")"
|
||||
if [[ -z "$REPORTED_COUNT" ]]; then
|
||||
fail "the clean run printed no 'APM agent validation passed: N ...' summary line — the script's contract with this test is gone"
|
||||
sed 's/^/ /' "$RUN_TMP/clean.out"
|
||||
elif [[ "$TRACKED_COUNT" -eq 0 ]]; then
|
||||
fail "git ls-files found 0 tracked agent files — this test's own expectation is broken, not the script's"
|
||||
elif [[ "$REPORTED_COUNT" -ne "$TRACKED_COUNT" ]]; then
|
||||
fail "the clean run validated $REPORTED_COUNT file(s) but $TRACKED_COUNT are tracked"
|
||||
else
|
||||
pass "validated $REPORTED_COUNT file(s), matching the $TRACKED_COUNT tracked in the index"
|
||||
fi
|
||||
|
||||
# --- 2. An invalid agent file fails, and both the file and the reason are named ---
|
||||
echo ""
|
||||
echo "--- an invalid agent file fails, naming the file and the reason ---"
|
||||
FIX2="$(make_fixture)"
|
||||
FIXTURES+=("$FIX2")
|
||||
# `tools:` is deliberately absent from field-inventory.md's apm-agent-allowlist:
|
||||
# its value shape differs per harness and apm compile copies frontmatter verbatim
|
||||
# to every target (ADR-0016).
|
||||
python3 - "$FIX2/plugins/lint/.apm/agents/lint-runner.agent.md" <<'PY'
|
||||
import sys
|
||||
p = sys.argv[1]
|
||||
s = open(p).read()
|
||||
open(p, 'w').write(s.replace('---\n', '---\ntools: Read, Write\n', 1))
|
||||
PY
|
||||
if bash "$SCRIPT" "$FIX2" > "$RUN_TMP/invalid.out" 2>&1; then
|
||||
fail "an agent file with a non-allowlisted frontmatter field still exited 0"
|
||||
sed 's/^/ /' "$RUN_TMP/invalid.out"
|
||||
elif ! grep -q 'plugins/lint/\.apm/agents/lint-runner\.agent\.md' "$RUN_TMP/invalid.out"; then
|
||||
fail "failed as expected but did not name the offending file"
|
||||
sed 's/^/ /' "$RUN_TMP/invalid.out"
|
||||
elif ! grep -q "field 'tools' is not in the vendor-neutral APM agent allowlist" "$RUN_TMP/invalid.out"; then
|
||||
fail "failed as expected and named the file but did not carry validate.sh's reason through"
|
||||
sed 's/^/ /' "$RUN_TMP/invalid.out"
|
||||
else
|
||||
pass "an invalid agent file exits 1, naming both the file and validate.sh's reason"
|
||||
fi
|
||||
|
||||
# --- 3. Zero discovered files is an error, not a pass ---
|
||||
echo ""
|
||||
echo "--- zero discovered agent files is an error ---"
|
||||
FIX3="$(make_fixture)"
|
||||
FIXTURES+=("$FIX3")
|
||||
rm -f "$FIX3/plugins/lint/.apm/agents/lint-runner.agent.md"
|
||||
if bash "$SCRIPT" "$FIX3" > "$RUN_TMP/empty.out" 2>&1; then
|
||||
fail "a tree with zero agent files exited 0 — the floor is gone and the gate is vacuous"
|
||||
sed 's/^/ /' "$RUN_TMP/empty.out"
|
||||
elif ! grep -q 'found 0 plugin-scope agent file' "$RUN_TMP/empty.out"; then
|
||||
fail "a tree with zero agent files exited non-zero but not for the zero-file reason"
|
||||
sed 's/^/ /' "$RUN_TMP/empty.out"
|
||||
else
|
||||
pass "a tree with zero agent files exits 1 and says so"
|
||||
fi
|
||||
|
||||
# --- 4. A tracked file missing from the worktree fails, derived from the index ---
|
||||
# This is the check a hardcoded count cannot make: the file is gone but the count
|
||||
# of what remains would still look plausible.
|
||||
echo ""
|
||||
echo "--- a tracked-but-deleted agent file fails against the derived expectation ---"
|
||||
FIX4="$(make_fixture)"
|
||||
FIXTURES+=("$FIX4")
|
||||
cat > "$FIX4/plugins/lint/.apm/agents/second-agent.agent.md" <<'MD'
|
||||
---
|
||||
name: second-agent
|
||||
description: A second agent, present only so its deletion leaves a plausible-looking non-empty set behind.
|
||||
---
|
||||
|
||||
Do the second thing.
|
||||
MD
|
||||
git -C "$FIX4" init -q
|
||||
git -C "$FIX4" add -A
|
||||
git -C "$FIX4" -c user.email=[email protected] -c user.name=t commit -qm "fixture"
|
||||
rm -f "$FIX4/plugins/lint/.apm/agents/second-agent.agent.md"
|
||||
if bash "$SCRIPT" "$FIX4" > "$RUN_TMP/missing.out" 2>&1; then
|
||||
fail "a tracked agent file deleted from the worktree still exited 0"
|
||||
sed 's/^/ /' "$RUN_TMP/missing.out"
|
||||
elif grep -q 'not a git worktree root' "$RUN_TMP/missing.out"; then
|
||||
fail "the fixture did not resolve as its own git worktree root, so the derived check never ran"
|
||||
sed 's/^/ /' "$RUN_TMP/missing.out"
|
||||
elif ! grep -q 'second-agent\.agent\.md' "$RUN_TMP/missing.out"; then
|
||||
fail "failed as expected but did not name the tracked file that went missing"
|
||||
sed 's/^/ /' "$RUN_TMP/missing.out"
|
||||
else
|
||||
pass "a tracked agent file deleted from the worktree exits 1 and is named"
|
||||
fi
|
||||
|
||||
# --- 5. An untracked agent file is still validated ---
|
||||
# The derived expectation is one-directional on purpose (tracked ⊆ discovered).
|
||||
# Work in progress must not fail the gate for being uncommitted — but it must
|
||||
# still be validated, or the gate would be trivially bypassed by not committing.
|
||||
echo ""
|
||||
echo "--- an untracked, invalid agent file still fails the gate ---"
|
||||
FIX5="$(make_fixture)"
|
||||
FIXTURES+=("$FIX5")
|
||||
git -C "$FIX5" init -q
|
||||
git -C "$FIX5" add -A
|
||||
git -C "$FIX5" -c user.email=[email protected] -c user.name=t commit -qm "fixture"
|
||||
cat > "$FIX5/plugins/lint/.apm/agents/wip-agent.agent.md" <<'MD'
|
||||
---
|
||||
name: wip-agent
|
||||
tools: Read, Write
|
||||
description: An uncommitted work-in-progress agent carrying a non-allowlisted field.
|
||||
---
|
||||
|
||||
Do the work-in-progress thing.
|
||||
MD
|
||||
if bash "$SCRIPT" "$FIX5" > "$RUN_TMP/untracked.out" 2>&1; then
|
||||
fail "an untracked, invalid agent file was not validated — the gate can be bypassed by not committing"
|
||||
sed 's/^/ /' "$RUN_TMP/untracked.out"
|
||||
elif ! grep -q 'wip-agent\.agent\.md' "$RUN_TMP/untracked.out"; then
|
||||
fail "failed but did not name the untracked file"
|
||||
sed 's/^/ /' "$RUN_TMP/untracked.out"
|
||||
else
|
||||
pass "an untracked, invalid agent file exits 1 and is named"
|
||||
fi
|
||||
|
||||
# --- 5b. An untracked but VALID agent file does not fail ---
|
||||
echo ""
|
||||
echo "--- an untracked, valid agent file passes ---"
|
||||
FIX5B="$(make_fixture)"
|
||||
FIXTURES+=("$FIX5B")
|
||||
git -C "$FIX5B" init -q
|
||||
git -C "$FIX5B" add -A
|
||||
git -C "$FIX5B" -c user.email=[email protected] -c user.name=t commit -qm "fixture"
|
||||
cat > "$FIX5B/plugins/lint/.apm/agents/wip-ok.agent.md" <<'MD'
|
||||
---
|
||||
name: wip-ok
|
||||
description: An uncommitted work-in-progress agent that is nonetheless entirely valid.
|
||||
---
|
||||
|
||||
Do the valid work-in-progress thing.
|
||||
MD
|
||||
if bash "$SCRIPT" "$FIX5B" > "$RUN_TMP/untracked-ok.out" 2>&1; then
|
||||
pass "an untracked but valid agent file does not fail the gate"
|
||||
else
|
||||
fail "an untracked but valid agent file failed the gate — uncommitted work must not be an error"
|
||||
sed 's/^/ /' "$RUN_TMP/untracked-ok.out"
|
||||
fi
|
||||
|
||||
# --- 6. A nonexistent REPO_ROOT fails loudly ---
|
||||
echo ""
|
||||
echo "--- a nonexistent REPO_ROOT fails loudly ---"
|
||||
if bash "$SCRIPT" "$RUN_TMP/does-not-exist" > "$RUN_TMP/norepo.out" 2>&1; then
|
||||
fail "a nonexistent REPO_ROOT exited 0"
|
||||
sed 's/^/ /' "$RUN_TMP/norepo.out"
|
||||
elif ! grep -q 'is not a directory' "$RUN_TMP/norepo.out"; then
|
||||
fail "a nonexistent REPO_ROOT failed for the wrong reason"
|
||||
sed 's/^/ /' "$RUN_TMP/norepo.out"
|
||||
else
|
||||
pass "a nonexistent REPO_ROOT exits 1 and says which path it was"
|
||||
fi
|
||||
|
||||
# --- 7. A missing validator is a hard failure, never a silent pass ---
|
||||
# The whole gate is void without validate.sh, and exit 0 here would be
|
||||
# indistinguishable from a run where every agent passed.
|
||||
echo ""
|
||||
echo "--- a missing validate.sh fails rather than validating nothing ---"
|
||||
FIX7="$(make_fixture)"
|
||||
FIXTURES+=("$FIX7")
|
||||
rm -f "$FIX7/plugins/kyberforge/.apm/skills/agent-audit/scripts/validate.sh"
|
||||
if bash "$SCRIPT" "$FIX7" > "$RUN_TMP/novalidator.out" 2>&1; then
|
||||
fail "a missing validate.sh exited 0 — the gate silently validated nothing"
|
||||
sed 's/^/ /' "$RUN_TMP/novalidator.out"
|
||||
elif ! grep -q 'validator not found' "$RUN_TMP/novalidator.out"; then
|
||||
fail "a missing validate.sh failed for the wrong reason"
|
||||
sed 's/^/ /' "$RUN_TMP/novalidator.out"
|
||||
else
|
||||
pass "a missing validate.sh exits 1 and names the stale path"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "Results: $PASS passed, $FAIL failed"
|
||||
[[ $FAIL -eq 0 ]]
|
||||
@@ -9,6 +9,56 @@ FAIL=0
|
||||
pass() { echo " PASS: $1"; PASS=$((PASS + 1)); }
|
||||
fail() { echo " FAIL: $1"; FAIL=$((FAIL + 1)); }
|
||||
|
||||
# Several distinct faults all end in exit 1, and the bugs fixed below were precisely
|
||||
# about the WRONG one being reported (a corrupt manifest blamed on six unlisted plugin
|
||||
# directories, a legal manifest blamed for unresolvable paths). Exit-code-only
|
||||
# assertions cannot see that, so these cases assert on the message text.
|
||||
RUN_OUT=""
|
||||
RUN_RC=0
|
||||
run_script() { RUN_OUT="$(bash "$SCRIPT" "$1" 2>&1)" && RUN_RC=0 || RUN_RC=$?; }
|
||||
|
||||
# assert_fails_with <fixture> <label> <expected substring>...
|
||||
assert_fails_with() {
|
||||
local fixture="$1" label="$2"
|
||||
shift 2
|
||||
run_script "$fixture"
|
||||
if [[ $RUN_RC -eq 0 ]]; then
|
||||
fail "$label -- expected exit 1, got 0. Output: $RUN_OUT"
|
||||
return
|
||||
fi
|
||||
local needle
|
||||
for needle in "$@"; do
|
||||
if [[ "$RUN_OUT" != *"$needle"* ]]; then
|
||||
fail "$label -- exited $RUN_RC but message lacked '$needle'. Output: $RUN_OUT"
|
||||
return
|
||||
fi
|
||||
done
|
||||
pass "$label"
|
||||
}
|
||||
|
||||
# assert_passes <fixture> <label>
|
||||
assert_passes() {
|
||||
run_script "$1"
|
||||
if [[ $RUN_RC -eq 0 ]]; then
|
||||
pass "$2"
|
||||
else
|
||||
fail "$2 -- expected exit 0, got $RUN_RC. Output: $RUN_OUT"
|
||||
fi
|
||||
}
|
||||
|
||||
# Writes a marketplace.json listing every "<name>=<source>" pair given.
|
||||
write_marketplace() {
|
||||
local dir="$1" entries="" pair name src
|
||||
shift
|
||||
for pair in "$@"; do
|
||||
name="${pair%%=*}"
|
||||
src="${pair#*=}"
|
||||
entries+="${entries:+,}"$'\n'" { \"name\": \"$name\", \"source\": \"$src\" }"
|
||||
done
|
||||
mkdir -p "$dir/.claude-plugin"
|
||||
printf '{\n "name": "test-marketplace",\n "plugins": [%s\n ]\n}\n' "$entries" > "$dir/.claude-plugin/marketplace.json"
|
||||
}
|
||||
|
||||
# One trap over a registry rather than a fresh `trap 'rm -rf "$FIXTUREn"' EXIT`
|
||||
# per fixture: each such trap REPLACES the previous one, so only the last
|
||||
# fixture was ever cleaned and the rest leaked into TMPDIR every run. Same
|
||||
@@ -393,6 +443,329 @@ else
|
||||
fail "flagged a listed plugin because its source: string was spelled differently"
|
||||
fi
|
||||
|
||||
# --- 11. A marketplace entry with no `source` at all is rejected outright ---
|
||||
# It used to disable BOTH directions of the check for that plugin at once:
|
||||
# list_marketplace_local_plugins requires a string `source`, so the entry was skipped and
|
||||
# its .claude-plugin/plugin.json never checked; and the disk -> marketplace name axis
|
||||
# selected on `(.source | type) != "string"`, which is TRUE for null, so the same entry
|
||||
# also marked its on-disk directory "listed". Net effect: a plugin with a broken manifest
|
||||
# and a malformed entry passed clean, and silently dropped out of
|
||||
# sync-plugin-content.sh --all's work list too, since that derives from the same helper.
|
||||
echo ""
|
||||
echo "--- a marketplace entry with no source: field is a hard error ---"
|
||||
FIXTURE11="$(mktemp -d)"
|
||||
FIXTURES+=("$FIXTURE11")
|
||||
mkdir -p "$FIXTURE11/.claude-plugin" "$FIXTURE11/plugins/lint"
|
||||
cat > "$FIXTURE11/.claude-plugin/marketplace.json" <<'JSON'
|
||||
{
|
||||
"name": "test-marketplace",
|
||||
"plugins": [
|
||||
{ "name": "lint" }
|
||||
]
|
||||
}
|
||||
JSON
|
||||
printf 'name: lint\nversion: 0.1.0\ntype: skill\n' > "$FIXTURE11/plugins/lint/apm.yml"
|
||||
assert_fails_with "$FIXTURE11" \
|
||||
"an entry with no source: is reported by name instead of silently disabling both checks" \
|
||||
'`source` is neither a local path string nor a remote source object' 'lint (source: null)'
|
||||
|
||||
# --- 11b. Any other unclassifiable `source` is rejected the same way ---
|
||||
# The guard used to test `.source == null` specifically, so every OTHER malformed value
|
||||
# reached exactly the state the null case was fixed for: `"source": 42` passed the
|
||||
# assert, was skipped by list_marketplace_local_plugins for not being a string, AND was
|
||||
# rescued by the disk -> marketplace name axis (whose select was the denylist
|
||||
# `(.source|type) != "string"`, true for a number). Verbatim the same defect, one value
|
||||
# over. Only two shapes are classifiable -- a local path string and a remote source
|
||||
# object -- so the guard is typed as "neither of those", not as a list of known-bad
|
||||
# values.
|
||||
echo ""
|
||||
echo "--- a non-string, non-object source: is rejected by type, not by enumerating null ---"
|
||||
for BAD_SOURCE in '42' '[]' 'true'; do
|
||||
FIXTURE11B="$(mktemp -d)"
|
||||
FIXTURES+=("$FIXTURE11B")
|
||||
mkdir -p "$FIXTURE11B/.claude-plugin" "$FIXTURE11B/plugins/lint"
|
||||
printf '{ "name": "test-marketplace", "plugins": [ { "name": "lint", "source": %s } ] }\n' \
|
||||
"$BAD_SOURCE" > "$FIXTURE11B/.claude-plugin/marketplace.json"
|
||||
printf 'name: lint\nversion: 0.1.0\ntype: skill\n' > "$FIXTURE11B/plugins/lint/apm.yml"
|
||||
assert_fails_with "$FIXTURE11B" \
|
||||
"a source: of $BAD_SOURCE is rejected instead of silently disabling both checks" \
|
||||
'`source` is neither a local path string nor a remote source object' 'lint (source:'
|
||||
done
|
||||
|
||||
# --- 11c. The disk -> marketplace name axis, exercised WITHOUT the precondition ---
|
||||
# This is the one assertion that cannot go through bash "$SCRIPT": every malformed entry
|
||||
# the select must reject is rejected first by assert_marketplace_manifest_usable, which
|
||||
# exits before the select ever runs. So reverting the select alone left the whole suite
|
||||
# green -- the code carried a comment claiming it "must not depend on that check running
|
||||
# first", and nothing tested that independence. Call the function directly instead.
|
||||
#
|
||||
# The invariant: the name axis exists solely for a plugin vendored on disk under a
|
||||
# REMOTE (object) `source:`, which has no local path to match on. Every other shape --
|
||||
# a local string (which matches by path and needs no name fallback, see case 9d) and
|
||||
# every unclassifiable value -- must produce no name at all.
|
||||
echo ""
|
||||
echo "--- list_marketplace_remote_plugin_names emits object-source names only ---"
|
||||
# shellcheck source=scripts/lib/marketplace-plugins.sh
|
||||
source "$REPO_ROOT/scripts/lib/marketplace-plugins.sh"
|
||||
FIXTURE11C="$(mktemp -d)"
|
||||
FIXTURES+=("$FIXTURE11C")
|
||||
cat > "$FIXTURE11C/marketplace.json" <<'JSON'
|
||||
{
|
||||
"name": "test-marketplace",
|
||||
"plugins": [
|
||||
{ "name": "remote-obj", "source": { "repo": "someorg/somerepo", "source": "github" } },
|
||||
{ "name": "local-str", "source": "./plugins/local-str" },
|
||||
{ "name": "null-src" },
|
||||
{ "name": "explicit-null", "source": null },
|
||||
{ "name": "number-src", "source": 42 },
|
||||
{ "name": "array-src", "source": [] },
|
||||
{ "name": "bool-src", "source": true }
|
||||
]
|
||||
}
|
||||
JSON
|
||||
NAMES11C="$(list_marketplace_remote_plugin_names "$FIXTURE11C/marketplace.json")"
|
||||
if [[ "$NAMES11C" == "remote-obj" ]]; then
|
||||
pass "only the remote object-source entry yields a name for the disk -> marketplace name axis"
|
||||
else
|
||||
fail "the name axis emitted $(printf '%s' "$NAMES11C" | tr '\n' ' ')— expected exactly 'remote-obj'; every other shape would rescue a same-named orphan directory"
|
||||
fi
|
||||
|
||||
# --- 11d. A valid-JSON, non-object marketplace root is named, not left to crash jq ---
|
||||
# `jq empty` passes on `[]`, `"x"` and `123`; the `.plugins` lookup on the next line then
|
||||
# died with a raw `jq: error: Cannot index array with string "plugins"` and rc=5,
|
||||
# attributed to nothing at all.
|
||||
echo ""
|
||||
echo "--- a valid-JSON non-object marketplace root is reported as such ---"
|
||||
FIXTURE11D="$(mktemp -d)"
|
||||
FIXTURES+=("$FIXTURE11D")
|
||||
mkdir -p "$FIXTURE11D/.claude-plugin" "$FIXTURE11D/plugins/one"
|
||||
printf '[]\n' > "$FIXTURE11D/.claude-plugin/marketplace.json"
|
||||
printf 'name: one\nversion: 0.1.0\ntype: skill\n' > "$FIXTURE11D/plugins/one/apm.yml"
|
||||
assert_fails_with "$FIXTURE11D" \
|
||||
"a JSON array at the marketplace root is named as a root-shape error" \
|
||||
'is a JSON array at its top level'
|
||||
|
||||
# --- 12. A `skills` string (a legal shape per the host docs) is resolved, not counted ---
|
||||
# `jq '.skills | if . then length else 0 end'` is null-safe but not type-safe: on the
|
||||
# string "./skills/x" it returned the CHARACTER count, and the `.skills[0]` that followed
|
||||
# errored ("Cannot index string with number"), killing the whole script under `set -e`
|
||||
# with no "Manifest check failed:" line -- and every plugin later in the marketplace
|
||||
# unchecked. Both configuration.md references document `skills` as string | string[].
|
||||
echo ""
|
||||
echo "--- a string-valued skills field resolves instead of crashing the script ---"
|
||||
FIXTURE12="$(mktemp -d)"
|
||||
FIXTURES+=("$FIXTURE12")
|
||||
mkdir -p "$FIXTURE12/plugins/strskills/.claude-plugin" "$FIXTURE12/plugins/strskills/custom/skills"
|
||||
write_marketplace "$FIXTURE12" "strskills=./plugins/strskills"
|
||||
cat > "$FIXTURE12/plugins/strskills/.claude-plugin/plugin.json" <<'JSON'
|
||||
{
|
||||
"name": "strskills",
|
||||
"skills": "./custom/skills/"
|
||||
}
|
||||
JSON
|
||||
assert_passes "$FIXTURE12" "a resolving string-valued skills field passes"
|
||||
|
||||
# --- 13. A broken string `skills` is reported, and later plugins are still checked ---
|
||||
# The mid-loop `set -e` abort meant a fault in the FIRST plugin hid every fault after it.
|
||||
# The second entry here is broken in an unrelated way; both messages must appear.
|
||||
echo ""
|
||||
echo "--- a broken string skills field is reported without aborting the marketplace walk ---"
|
||||
FIXTURE13="$(mktemp -d)"
|
||||
FIXTURES+=("$FIXTURE13")
|
||||
mkdir -p "$FIXTURE13/plugins/first/.claude-plugin" "$FIXTURE13/plugins/second"
|
||||
write_marketplace "$FIXTURE13" "first=./plugins/first" "second=./plugins/second"
|
||||
cat > "$FIXTURE13/plugins/first/.claude-plugin/plugin.json" <<'JSON'
|
||||
{
|
||||
"name": "first",
|
||||
"skills": "./skills/does-not-exist"
|
||||
}
|
||||
JSON
|
||||
assert_fails_with "$FIXTURE13" \
|
||||
"a broken string skills field is reported and the walk continues to later plugins" \
|
||||
'skills path not found: ./skills/does-not-exist' \
|
||||
"plugin 'second': .claude-plugin/plugin.json not found" \
|
||||
'Manifest check failed: 2 error(s)'
|
||||
|
||||
# --- 14. A genuinely wrong-typed `skills` is named as such, walk still continues ---
|
||||
echo ""
|
||||
echo "--- a wrong-typed skills field is reported as a type error, not a missing path ---"
|
||||
FIXTURE14="$(mktemp -d)"
|
||||
FIXTURES+=("$FIXTURE14")
|
||||
mkdir -p "$FIXTURE14/plugins/first/.claude-plugin" "$FIXTURE14/plugins/second"
|
||||
write_marketplace "$FIXTURE14" "first=./plugins/first" "second=./plugins/second"
|
||||
cat > "$FIXTURE14/plugins/first/.claude-plugin/plugin.json" <<'JSON'
|
||||
{
|
||||
"name": "first",
|
||||
"skills": 42
|
||||
}
|
||||
JSON
|
||||
assert_fails_with "$FIXTURE14" \
|
||||
"a wrong-typed skills field names the type and does not abort the walk" \
|
||||
'skills must be a path string, an array of path strings, or an inline object, got number' \
|
||||
"plugin 'second': .claude-plugin/plugin.json not found" \
|
||||
'Manifest check failed: 2 error(s)'
|
||||
|
||||
# --- 15. Array- and object-valued pointer fields that resolve are not reported missing ---
|
||||
# `ref=$(jq -r ".$field // empty")` returned the PRETTY-PRINTED JSON for an array or an
|
||||
# object, which `[[ ! -e ]]` then rejected: a manifest whose paths all resolve was
|
||||
# reported broken. Both host docs give `agents` as string | string[] and `hooks` /
|
||||
# `mcpServers` as string | object (an inline definition, with no path to resolve).
|
||||
echo ""
|
||||
echo "--- array- and inline-object pointer fields that resolve are accepted ---"
|
||||
FIXTURE15="$(mktemp -d)"
|
||||
FIXTURES+=("$FIXTURE15")
|
||||
mkdir -p "$FIXTURE15/plugins/shapes/.claude-plugin" "$FIXTURE15/plugins/shapes/agents" "$FIXTURE15/plugins/shapes/skills/one"
|
||||
touch "$FIXTURE15/plugins/shapes/agents/real.md"
|
||||
write_marketplace "$FIXTURE15" "shapes=./plugins/shapes"
|
||||
cat > "$FIXTURE15/plugins/shapes/.claude-plugin/plugin.json" <<'JSON'
|
||||
{
|
||||
"name": "shapes",
|
||||
"skills": ["./skills/one"],
|
||||
"agents": ["./agents/real.md"],
|
||||
"hooks": { "PreToolUse": [{ "hooks": [{ "type": "command", "command": "true" }] }] },
|
||||
"mcpServers": { "demo": { "command": "true" } }
|
||||
}
|
||||
JSON
|
||||
assert_passes "$FIXTURE15" \
|
||||
"an array-valued agents and an inline-object hooks/mcpServers are not reported missing"
|
||||
|
||||
# --- 16. A broken element inside an array-valued pointer field is still caught ---
|
||||
# Guards the fix in #15 against over-correcting into "arrays are always fine".
|
||||
echo ""
|
||||
echo "--- a broken path inside an array-valued pointer field is still caught ---"
|
||||
FIXTURE16="$(mktemp -d)"
|
||||
FIXTURES+=("$FIXTURE16")
|
||||
mkdir -p "$FIXTURE16/plugins/shapes/.claude-plugin" "$FIXTURE16/plugins/shapes/agents"
|
||||
touch "$FIXTURE16/plugins/shapes/agents/real.md"
|
||||
write_marketplace "$FIXTURE16" "shapes=./plugins/shapes"
|
||||
cat > "$FIXTURE16/plugins/shapes/.claude-plugin/plugin.json" <<'JSON'
|
||||
{
|
||||
"name": "shapes",
|
||||
"agents": ["./agents/real.md", "./agents/ghost.md"]
|
||||
}
|
||||
JSON
|
||||
assert_fails_with "$FIXTURE16" \
|
||||
"a missing path in an array-valued agents field is reported with its own path" \
|
||||
'agents path not found: ./agents/ghost.md'
|
||||
|
||||
# --- 17. An unparseable marketplace.json is reported as such, not as unlisted plugins ---
|
||||
# The walk runs in a process substitution, so the helper's `set -e` abort on invalid JSON
|
||||
# never reached the caller. The run still exited 1 -- backstopped by the disk -> marketplace
|
||||
# pass -- but printed one "has no entry in .claude-plugin/marketplace.json ... add it to
|
||||
# root apm.yml" per plugin directory, sending the reader to edit apm.yml when the actual
|
||||
# fault was a corrupt manifest.
|
||||
echo ""
|
||||
echo "--- an unparseable marketplace.json is attributed to the manifest, not to the plugins ---"
|
||||
FIXTURE17="$(mktemp -d)"
|
||||
FIXTURES+=("$FIXTURE17")
|
||||
mkdir -p "$FIXTURE17/.claude-plugin" "$FIXTURE17/plugins/one/.claude-plugin" "$FIXTURE17/plugins/two/.claude-plugin"
|
||||
printf '{ "name": "test-marketplace", "plugins": [ { "name": "one",\n' > "$FIXTURE17/.claude-plugin/marketplace.json"
|
||||
echo '{ "name": "one" }' > "$FIXTURE17/plugins/one/.claude-plugin/plugin.json"
|
||||
echo '{ "name": "two" }' > "$FIXTURE17/plugins/two/.claude-plugin/plugin.json"
|
||||
run_script "$FIXTURE17"
|
||||
if [[ $RUN_RC -eq 0 ]]; then
|
||||
fail "exited 0 on an unparseable marketplace.json -- expected exit 1"
|
||||
elif [[ "$RUN_OUT" != *"is not valid JSON"* ]]; then
|
||||
fail "an unparseable marketplace.json was not named as such. Output: $RUN_OUT"
|
||||
elif [[ "$RUN_OUT" == *"has no entry in .claude-plugin/marketplace.json"* ]]; then
|
||||
fail "an unparseable marketplace.json was misreported as unlisted plugin directories. Output: $RUN_OUT"
|
||||
else
|
||||
pass "an unparseable marketplace.json is reported as invalid JSON, not as unlisted plugin directories"
|
||||
fi
|
||||
|
||||
# --- 18. A missing marketplace.json with plugins on disk is drift, not an opt-out ---
|
||||
# `[[ ! -f "$MARKETPLACE" ]] && exit 0` was the same empty-set-reads-as-pass shape as the
|
||||
# rest: per ADR-0015 the manifest is compiled from root apm.yml, so its absence next to
|
||||
# on-disk packages means the compiled output is missing, and every marketplace-derived
|
||||
# gate walks an empty plugin set in silence.
|
||||
echo ""
|
||||
echo "--- a missing marketplace.json alongside on-disk plugin directories fails ---"
|
||||
FIXTURE18="$(mktemp -d)"
|
||||
FIXTURES+=("$FIXTURE18")
|
||||
mkdir -p "$FIXTURE18/plugins/orphan/.apm/skills"
|
||||
assert_fails_with "$FIXTURE18" \
|
||||
"a missing marketplace.json with plugin directories present is reported as drift" \
|
||||
'.claude-plugin/marketplace.json does not exist' \
|
||||
'plugins/orphan'
|
||||
|
||||
# --- 18b. A missing marketplace.json with nothing to check still exits 0 ---
|
||||
# Guards the fix above against over-correcting into "always fail without a manifest":
|
||||
# a repo with no plugin directories genuinely has nothing for this gate to check.
|
||||
echo ""
|
||||
echo "--- a missing marketplace.json with no plugin directories still exits 0 ---"
|
||||
FIXTURE18B="$(mktemp -d)"
|
||||
FIXTURES+=("$FIXTURE18B")
|
||||
mkdir -p "$FIXTURE18B/plugins/scratch/notes" "$FIXTURE18B/docs"
|
||||
assert_passes "$FIXTURE18B" \
|
||||
"no marketplace.json and no plugin-marked directories is a genuine no-op, not a failure"
|
||||
|
||||
# --- 19. An unparseable per-plugin plugin.json is attributed, and the walk continues ---
|
||||
# check_pointer_field reads the manifest with bare `$(jq ...)` assignments, so under
|
||||
# `set -e` a parse failure aborted the whole script mid-loop: rc=5, a raw
|
||||
# `jq: parse error` on stderr, no `Manifest check failed:` summary, and every plugin
|
||||
# later in the marketplace silently unchecked. That is the same failure class the
|
||||
# marketplace's own `jq empty` precondition closes -- and .claude-plugin/plugin.json is
|
||||
# equally generated output, so it is equally capable of being corrupt.
|
||||
#
|
||||
# The second entry is broken in an unrelated way; both messages plus the summary must
|
||||
# appear, which is what proves the walk survived the first fault.
|
||||
echo ""
|
||||
echo "--- an unparseable plugin.json is reported and does not abort the marketplace walk ---"
|
||||
FIXTURE19="$(mktemp -d)"
|
||||
FIXTURES+=("$FIXTURE19")
|
||||
mkdir -p "$FIXTURE19/plugins/corrupt/.claude-plugin" "$FIXTURE19/plugins/second"
|
||||
write_marketplace "$FIXTURE19" "corrupt=./plugins/corrupt" "second=./plugins/second"
|
||||
printf '{ "name": "corrupt",\n' > "$FIXTURE19/plugins/corrupt/.claude-plugin/plugin.json"
|
||||
assert_fails_with "$FIXTURE19" \
|
||||
"an unparseable plugin.json is named and later plugins are still checked" \
|
||||
"plugin 'corrupt': .claude-plugin/plugin.json is not valid JSON" \
|
||||
"plugin 'second': .claude-plugin/plugin.json not found" \
|
||||
'Manifest check failed: 2 error(s)'
|
||||
|
||||
# --- 19b. A valid-JSON but non-object plugin.json is caught too ---
|
||||
# `jq empty` passes on `[]`; it is the `.skills` lookup on such a root that aborts
|
||||
# ("Cannot index array with string"), not the parse -- so the parse check alone would
|
||||
# leave this exact crash reachable.
|
||||
echo ""
|
||||
echo "--- a valid-JSON non-object plugin.json is reported, not left to crash jq ---"
|
||||
FIXTURE19B="$(mktemp -d)"
|
||||
FIXTURES+=("$FIXTURE19B")
|
||||
mkdir -p "$FIXTURE19B/plugins/arrayjson/.claude-plugin" "$FIXTURE19B/plugins/second"
|
||||
write_marketplace "$FIXTURE19B" "arrayjson=./plugins/arrayjson" "second=./plugins/second"
|
||||
printf '[]\n' > "$FIXTURE19B/plugins/arrayjson/.claude-plugin/plugin.json"
|
||||
assert_fails_with "$FIXTURE19B" \
|
||||
"a non-object plugin.json is named by type and later plugins are still checked" \
|
||||
"plugin 'arrayjson': .claude-plugin/plugin.json is a JSON array at its top level" \
|
||||
"plugin 'second': .claude-plugin/plugin.json not found" \
|
||||
'Manifest check failed: 2 error(s)'
|
||||
|
||||
# --- 20. Run with no argument outside a worktree: refuse, do not guess $PWD ---
|
||||
# Every path this script touches hangs off REPO_ROOT, and its exit-0 path is "no
|
||||
# manifest and nothing on disk" -- so `|| pwd` made a run from an empty directory
|
||||
# outside any worktree exit 0, silently, having inspected no repository at all. Same
|
||||
# reasoning as scripts/sync-marketplace-mirror.sh, which dropped its fallback first.
|
||||
#
|
||||
# `env -u GIT_DIR -u GIT_WORK_TREE` because run-tests.sh runs as a pre-push hook and git
|
||||
# hooks export both, which would re-target `git rev-parse --show-toplevel` at the LIVE
|
||||
# repo from any cwd -- making this case pass for the wrong reason.
|
||||
echo ""
|
||||
echo "--- with no argument outside a git worktree, it refuses instead of guessing \$PWD ---"
|
||||
FIXTURE20="$(mktemp -d)"
|
||||
FIXTURES+=("$FIXTURE20")
|
||||
if (cd "$FIXTURE20" && env -u GIT_DIR -u GIT_WORK_TREE git rev-parse --show-toplevel) >/dev/null 2>&1; then
|
||||
fail "fixture precondition: $FIXTURE20 is inside a git worktree, so this case cannot test the no-worktree path"
|
||||
else
|
||||
RC20=0
|
||||
OUT20="$(cd "$FIXTURE20" && env -u GIT_DIR -u GIT_WORK_TREE bash "$SCRIPT" 2>&1)" || RC20=$?
|
||||
if [[ $RC20 -eq 0 ]]; then
|
||||
fail "exited 0 from outside a worktree with no argument -- it checked nothing and said so to no one"
|
||||
elif [[ "$OUT20" != *"not inside a git worktree"* ]]; then
|
||||
fail "exited $RC20 outside a worktree but not for the stated reason. Output: $OUT20"
|
||||
else
|
||||
pass "refuses to guess \$PWD when it cannot locate the repository root"
|
||||
fi
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "Results: $PASS passed, $FAIL failed"
|
||||
[[ $FAIL -eq 0 ]]
|
||||
@@ -20,40 +20,10 @@ trap cleanup EXIT
|
||||
RUN_TMP="$(mktemp -d)"
|
||||
FIXTURES+=("$RUN_TMP")
|
||||
|
||||
# --- 1. Exits 0 against this repo's own (fixed) scripts ---
|
||||
echo ""
|
||||
echo "--- exits 0 against this repo's real scripts ---"
|
||||
if bash "$SCRIPT" "$REPO_ROOT" > "$RUN_TMP/clean.out" 2>&1; then
|
||||
pass "exits 0 against this repo's real scope walk-up scripts"
|
||||
else
|
||||
fail "exited non-zero against this repo's real (already-fixed) scripts"
|
||||
sed 's/^/ /' "$RUN_TMP/clean.out"
|
||||
fi
|
||||
|
||||
# --- 2. Exits 0 as a no-op when the kyberforge skills aren't present ---
|
||||
echo ""
|
||||
echo "--- exits 0 (no-op) when the target scripts don't exist ---"
|
||||
FIXTURE_EMPTY="$(mktemp -d)"
|
||||
FIXTURES+=("$FIXTURE_EMPTY")
|
||||
if bash "$SCRIPT" "$FIXTURE_EMPTY" > /dev/null 2>&1; then
|
||||
pass "exits 0 as a no-op when agent-author/agent-audit/skill-author aren't present"
|
||||
else
|
||||
fail "exited non-zero when the kyberforge skills are simply absent"
|
||||
fi
|
||||
|
||||
# --- 3. Exits 1 against a REPO_ROOT that doesn't exist ---
|
||||
echo ""
|
||||
echo "--- exits 1 when REPO_ROOT does not exist ---"
|
||||
if bash "$SCRIPT" "/nonexistent/path/$(date +%s)-$$" > /dev/null 2>&1; then
|
||||
fail "exited 0 for a nonexistent REPO_ROOT — expected exit 1"
|
||||
else
|
||||
pass "exits non-zero for a nonexistent REPO_ROOT"
|
||||
fi
|
||||
|
||||
# --- 4. Regression guard: reintroducing the $HOME-collapse bug into
|
||||
# validate.sh's detect_scope must make the check fail. Builds a minimal
|
||||
# REPO_ROOT (just the four scripts, at their real relative paths) so this
|
||||
# doesn't depend on — or risk mutating — the real repo tree.
|
||||
# Builds a minimal REPO_ROOT (just the four scripts, at their real relative
|
||||
# paths) so the mutation cases below don't depend on — or risk mutating — the
|
||||
# real repo tree. Defined up here rather than beside its first mutation case
|
||||
# because case 2b's stale-.apm/ fixture is built from it too.
|
||||
make_minimal_repo_root() {
|
||||
local dir
|
||||
dir="$(mktemp -d)"
|
||||
@@ -75,6 +45,85 @@ make_minimal_repo_root() {
|
||||
echo "$dir"
|
||||
}
|
||||
|
||||
# --- 1. Exits 0 against this repo's own (fixed) scripts ---
|
||||
echo ""
|
||||
echo "--- exits 0 against this repo's real scripts ---"
|
||||
if bash "$SCRIPT" "$REPO_ROOT" > "$RUN_TMP/clean.out" 2>&1; then
|
||||
pass "exits 0 against this repo's real scope walk-up scripts"
|
||||
else
|
||||
fail "exited non-zero against this repo's real (already-fixed) scripts"
|
||||
sed 's/^/ /' "$RUN_TMP/clean.out"
|
||||
fi
|
||||
|
||||
# --- 1b. Positive: that exit 0 was earned, not vacuous ---
|
||||
# Every other case here runs against a synthetic fixture, and exit 0 is also
|
||||
# what the script produces when it finds nothing to check at all. So the
|
||||
# assertion above passes just as happily on a run that executed zero fixtures.
|
||||
# The `ok:` lines are the record of work actually done; each of the seven
|
||||
# fixtures emits at least one, so a floor of 7 catches a whole fixture going
|
||||
# dark as well as the all-or-nothing case (13 at the time of writing — the floor
|
||||
# is deliberately below that so adding assertions to a fixture doesn't churn it).
|
||||
CLEAN_OKS="$(grep -c '^ ok:' "$RUN_TMP/clean.out" || true)"
|
||||
if [[ "$CLEAN_OKS" -ge 7 ]]; then
|
||||
pass "the clean run against this repo actually exercised its fixtures ($CLEAN_OKS ok assertions)"
|
||||
else
|
||||
fail "the clean run against this repo reported only $CLEAN_OKS ok assertions (expected at least one per fixture) — exit 0 without the fixtures having run means nothing was checked"
|
||||
sed 's/^/ /' "$RUN_TMP/clean.out"
|
||||
fi
|
||||
|
||||
# --- 2. Exits 0 as a no-op ONLY when there is no kyberforge plugin at all ---
|
||||
# The no-op is scoped to a repo that never installed kyberforge. Case 2b below is
|
||||
# its counterpart and the one that matters.
|
||||
echo ""
|
||||
echo "--- exits 0 (no-op) when there is no plugins/kyberforge at all ---"
|
||||
FIXTURE_EMPTY="$(mktemp -d)"
|
||||
FIXTURES+=("$FIXTURE_EMPTY")
|
||||
if [[ -e "$FIXTURE_EMPTY/plugins/kyberforge" ]]; then
|
||||
fail "the empty fixture unexpectedly has a plugins/kyberforge, so it does not exercise the no-kyberforge no-op"
|
||||
elif bash "$SCRIPT" "$FIXTURE_EMPTY" > /dev/null 2>&1; then
|
||||
pass "exits 0 as a no-op when the repo has no kyberforge plugin"
|
||||
else
|
||||
fail "exited non-zero when the repo simply has no kyberforge plugin"
|
||||
fi
|
||||
|
||||
# --- 2b. Exits 1, saying so, when plugins/kyberforge exists but the .apm/
|
||||
# scripts under it do not ---
|
||||
# The four target paths are hardcoded as plugins/kyberforge/.apm/skills/... with
|
||||
# no floor under them: `mv plugins/kyberforge/.apm plugins/kyberforge/.apm2` hit
|
||||
# the "not present, nothing to check" branch and exited 0, indistinguishable
|
||||
# from "all four implementations agree" and swallowed by pre-commit as `Passed`.
|
||||
# A path rewrite is exactly the edit that produces this, and it is what this PR
|
||||
# did to these paths.
|
||||
#
|
||||
# Asserted on the MESSAGE, not just the code: this script exits 1 for any fixture
|
||||
# disagreement too, so the code alone would not tell a stale path from a genuine
|
||||
# walk-up regression — and those call for opposite fixes.
|
||||
echo ""
|
||||
echo "--- exits 1 and says so when plugins/kyberforge exists but .apm/ does not ---"
|
||||
FIXTURE_STALE="$(make_minimal_repo_root)"
|
||||
FIXTURES+=("$FIXTURE_STALE")
|
||||
mv "$FIXTURE_STALE/plugins/kyberforge/.apm" "$FIXTURE_STALE/plugins/kyberforge/.apm2"
|
||||
STALE_RC=0
|
||||
bash "$SCRIPT" "$FIXTURE_STALE" > "$RUN_TMP/stale.out" 2>&1 || STALE_RC=$?
|
||||
if [[ $STALE_RC -eq 0 ]]; then
|
||||
fail "exited 0 when plugins/kyberforge exists but its .apm/ scripts are gone — expected exit 1"
|
||||
elif ! grep -q "\.apm/ paths have gone stale" "$RUN_TMP/stale.out"; then
|
||||
fail "failed for the wrong reason on a stale .apm/ path: $(tr '\n' ' ' < "$RUN_TMP/stale.out")"
|
||||
else
|
||||
pass "exits non-zero and reports a stale .apm/ path when plugins/kyberforge exists without it"
|
||||
fi
|
||||
|
||||
# --- 3. Exits 1 against a REPO_ROOT that doesn't exist ---
|
||||
echo ""
|
||||
echo "--- exits 1 when REPO_ROOT does not exist ---"
|
||||
if bash "$SCRIPT" "/nonexistent/path/$(date +%s)-$$" > /dev/null 2>&1; then
|
||||
fail "exited 0 for a nonexistent REPO_ROOT — expected exit 1"
|
||||
else
|
||||
pass "exits non-zero for a nonexistent REPO_ROOT"
|
||||
fi
|
||||
|
||||
# --- 4. Regression guard: reintroducing the $HOME-collapse bug into
|
||||
# validate.sh's detect_scope must make the check fail.
|
||||
echo ""
|
||||
echo "--- exits 1 when validate.sh's detect_scope collapses back to the \$HOME-walk-up bug ---"
|
||||
FIXTURE_BUG="$(make_minimal_repo_root)"
|
||||
|
||||
@@ -9,6 +9,28 @@ FAIL=0
|
||||
pass() { echo " PASS: $1"; PASS=$((PASS + 1)); }
|
||||
fail() { echo " FAIL: $1"; FAIL=$((FAIL + 1)); }
|
||||
|
||||
# Same `exit 77` (automake convention; run-tests.sh renders it as SKIPPED) guard
|
||||
# tests/test-vale-wrap.sh and tests/test-sync-plugin-content.sh use for a missing
|
||||
# binary. Without it this suite reported 5 genuine failures on a machine with no
|
||||
# vale, none of which were regressions.
|
||||
#
|
||||
# The suite SKIPPING while the script it tests HARD-FAILS is deliberate, not an
|
||||
# inconsistency. The script is a pre-push gate whose exit 0 is a claim that the
|
||||
# repo was verified, and six of its assertions are vale invocations — it must
|
||||
# never make that claim on a machine where they could not run. This suite makes
|
||||
# no claim about the repo; it claims the script behaves correctly, and most of
|
||||
# its cases (every glob-coverage case, 10/10b/11) cannot be exercised at all
|
||||
# without vale. Reporting those as FAIL would say "a regression landed" when the
|
||||
# truth is "this machine is missing a dev dependency" — noise that competes with
|
||||
# real failures. Note also that the vale-absent behavior is still fully covered
|
||||
# here even so: the masking below constructs that condition deliberately on a
|
||||
# machine that HAS vale, which is the only place it can be asserted against a
|
||||
# known-good baseline.
|
||||
if ! command -v vale &>/dev/null; then
|
||||
echo "SKIP: vale is not installed — the glob-coverage cases cannot run (install it: https://vale.sh/docs/vale-cli/installation/)"
|
||||
exit 77
|
||||
fi
|
||||
|
||||
# One trap over a registry, rather than rebuilding the trap line per fixture:
|
||||
# the guard is there because bash 3.2 treats "${arr[@]}" on an empty array as
|
||||
# unbound under `set -u`.
|
||||
@@ -89,9 +111,18 @@ fi
|
||||
# Runs the check with vale masked off PATH when that is safe. For text-only
|
||||
# assertions ONLY — never for a case whose verdict depends on a glob probe
|
||||
# actually running.
|
||||
#
|
||||
# CHECK_VALE_STYLE_SYNC_ALLOW_MISSING_VALE=1 is required now that the script
|
||||
# treats a missing vale as a FAIL rather than a warning: without the opt-out
|
||||
# every masked run exits 1 unconditionally and every negative case below would
|
||||
# pass vacuously — the precise vacuity this whole round is closing. The opt-out
|
||||
# restores what masking is for here: the text assertion under test becomes the
|
||||
# only thing that can produce a non-zero exit. Case 12 asserts the un-opted-out
|
||||
# masked run really does hard-fail, so this env var cannot quietly become the
|
||||
# only path anyone exercises.
|
||||
run_check_no_vale() {
|
||||
if [[ "$VALE_MASKED" == true ]]; then
|
||||
PATH="$PATH_NO_VALE" bash "$SCRIPT" "$@"
|
||||
PATH="$PATH_NO_VALE" CHECK_VALE_STYLE_SYNC_ALLOW_MISSING_VALE=1 bash "$SCRIPT" "$@"
|
||||
else
|
||||
bash "$SCRIPT" "$@"
|
||||
fi
|
||||
@@ -151,15 +182,152 @@ else
|
||||
pass "exits non-zero when a rule file is missing from one copy"
|
||||
fi
|
||||
|
||||
# --- 5. Exits 0 (no-op) when kyberforge isn't present in the target repo ---
|
||||
# --- 5. Exits 0 (no-op) ONLY when there is no kyberforge plugin at all ---
|
||||
# The no-op is scoped to a repo that never installed kyberforge. Case 5c below
|
||||
# is its counterpart and the one that matters: `plugins/kyberforge/` present but
|
||||
# the `.apm/` targets under it absent is drift, not absence.
|
||||
echo ""
|
||||
echo "--- exits 0 when kyberforge skills are absent (no-op) ---"
|
||||
echo "--- exits 0 when there is no plugins/kyberforge at all (no-op) ---"
|
||||
FIXTURE5="$(mktemp -d)"
|
||||
FIXTURES+=("$FIXTURE5")
|
||||
if bash "$SCRIPT" "$FIXTURE5" > /dev/null 2>&1; then
|
||||
pass "exits 0 as a no-op when skill-audit/agent-audit don't exist"
|
||||
if [[ -e "$FIXTURE5/plugins/kyberforge" ]]; then
|
||||
fail "fixture 5 unexpectedly has a plugins/kyberforge, so it does not exercise the no-kyberforge no-op"
|
||||
elif bash "$SCRIPT" "$FIXTURE5" > /dev/null 2>&1; then
|
||||
pass "exits 0 as a no-op when the repo has no kyberforge plugin"
|
||||
else
|
||||
fail "exited non-zero when skill-audit/agent-audit are simply absent"
|
||||
fail "exited non-zero when the repo simply has no kyberforge plugin"
|
||||
fi
|
||||
|
||||
# --- 5c. Exits 1, saying so, when plugins/kyberforge exists but its .apm/
|
||||
# targets do not ---
|
||||
# This script hardcodes plugins/kyberforge/.apm/skills/{skill-audit,agent-audit}
|
||||
# and had no floor under them: `mv plugins/kyberforge/.apm plugins/kyberforge/.apm2`
|
||||
# made both directories absent, which fell into the no-op above and exited 0 —
|
||||
# indistinguishable from a verified in-sync result, and swallowed by pre-commit
|
||||
# as `Passed`. A path rewrite is exactly the edit that produces this, and it is
|
||||
# what this PR did to these paths.
|
||||
#
|
||||
# Exit code alone proves little here (the script exits 1 for a dozen reasons), so
|
||||
# assert the MESSAGE: deleting the floor leaves exit 0, but a floor that fired
|
||||
# for the wrong reason would still be a bug this case must catch.
|
||||
echo ""
|
||||
echo "--- exits 1 and says so when plugins/kyberforge exists but .apm/ does not ---"
|
||||
FIXTURE5C="$(make_fixture)"
|
||||
FIXTURES+=("$FIXTURE5C")
|
||||
mv "$FIXTURE5C/plugins/kyberforge/.apm" "$FIXTURE5C/plugins/kyberforge/.apm2"
|
||||
STALE_OUT=""
|
||||
STALE_RC=0
|
||||
STALE_OUT="$(bash "$SCRIPT" "$FIXTURE5C" 2>&1)" || STALE_RC=$?
|
||||
if [[ $STALE_RC -eq 0 ]]; then
|
||||
fail "exited 0 when plugins/kyberforge exists but its .apm/ targets are gone — expected exit 1"
|
||||
elif ! printf '%s\n' "$STALE_OUT" | grep -q "\.apm/ paths have gone stale"; then
|
||||
fail "failed for the wrong reason on a stale .apm/ path: $(printf '%s' "$STALE_OUT" | tr '\n' ' ')"
|
||||
else
|
||||
pass "exits non-zero and reports a stale .apm/ path when plugins/kyberforge exists without it"
|
||||
fi
|
||||
|
||||
# --- 5d/5d2. Exits 1, saying so, when the probe TABLE itself verifies nothing ---
|
||||
# 5d used to relocate `assets/vale/` in both skills, on the belief that doing so
|
||||
# skipped the whole probe table with FAIL still at 0. It does not. Run against
|
||||
# the PRE-guard script that fixture already exited 1 with three errors: the
|
||||
# `.vale.ini` loop errs on both missing files long before the probe loop, and
|
||||
# PROBES_CHECKED can only reach 0 when both files are gone — which necessarily
|
||||
# means FAIL >= 2. So it never exercised the guard as a cause, only checked that
|
||||
# its message showed up beside unrelated failures.
|
||||
#
|
||||
# The guard is still worth having, but its real triggers live in the probe table,
|
||||
# which is part of the script rather than the fixture — so these two cases mutate
|
||||
# a COPY of the script and run that. Both assert `1 error(s)`, which is what makes
|
||||
# them real: with the guard deleted each mutation exits 0, and with it present the
|
||||
# guard is provably the only thing that failed the run.
|
||||
assert_mutated() {
|
||||
if diff -q "$SCRIPT" "$1" >/dev/null 2>&1; then
|
||||
fail "the script mutation changed nothing — the probe table's shape has moved, so this case would pass vacuously"
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
echo ""
|
||||
echo "--- exits 1 and says so when every probe row names a directory that does not exist ---"
|
||||
FIXTURE5D="$(make_fixture)"
|
||||
FIXTURES+=("$FIXTURE5D")
|
||||
SCRATCH5D="$(mktemp -d)"
|
||||
FIXTURES+=("$SCRATCH5D")
|
||||
sed 's/^skill-audit|/skill-auditX|/; s/^agent-audit|/agent-auditX|/' "$SCRIPT" > "$SCRATCH5D/drifted.sh"
|
||||
NOPROBE_OUT=""
|
||||
NOPROBE_RC=0
|
||||
if assert_mutated "$SCRATCH5D/drifted.sh"; then
|
||||
NOPROBE_OUT="$(bash "$SCRATCH5D/drifted.sh" "$FIXTURE5D" 2>&1)" || NOPROBE_RC=$?
|
||||
if [[ $NOPROBE_RC -eq 0 ]]; then
|
||||
fail "a probe table naming no existing skill directory exited 0 — the glob-coverage section checked nothing and reported success"
|
||||
elif ! printf '%s\n' "$NOPROBE_OUT" | grep -q "no probe path was checked"; then
|
||||
fail "did not report that zero probe paths were checked: $(printf '%s' "$NOPROBE_OUT" | tr '\n' ' ')"
|
||||
elif ! printf '%s\n' "$NOPROBE_OUT" | grep -q "failed: 1 error(s)"; then
|
||||
fail "drifted probe rows failed for reasons beyond the empty probe table, so this guard is not provably what fired: $(printf '%s' "$NOPROBE_OUT" | tr '\n' ' ')"
|
||||
else
|
||||
pass "a probe table whose rows name no existing skill directory fails with that guard as the sole error"
|
||||
fi
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "--- exits 1 and says so when the probe table is empty ---"
|
||||
FIXTURE5D2="$(make_fixture)"
|
||||
FIXTURES+=("$FIXTURE5D2")
|
||||
SCRATCH5D2="$(mktemp -d)"
|
||||
FIXTURES+=("$SCRATCH5D2")
|
||||
# The other reachable trigger: the heredoc gutted outright by a bad merge or a
|
||||
# truncated edit. `done <<'EOF_PROBE'` with no rows between the delimiters is
|
||||
# valid bash — the loop body simply never runs.
|
||||
awk '
|
||||
/^done <<.EOF_PROBE.$/ { print; inblk = 1; next }
|
||||
inblk && /^EOF_PROBE$/ { print; inblk = 0; next }
|
||||
inblk { next }
|
||||
{ print }
|
||||
' "$SCRIPT" > "$SCRATCH5D2/gutted.sh"
|
||||
EMPTYTBL_OUT=""
|
||||
EMPTYTBL_RC=0
|
||||
if assert_mutated "$SCRATCH5D2/gutted.sh"; then
|
||||
EMPTYTBL_OUT="$(bash "$SCRATCH5D2/gutted.sh" "$FIXTURE5D2" 2>&1)" || EMPTYTBL_RC=$?
|
||||
if [[ $EMPTYTBL_RC -eq 0 ]]; then
|
||||
fail "an empty probe table exited 0 — the glob-coverage section verified nothing and reported success"
|
||||
elif ! printf '%s\n' "$EMPTYTBL_OUT" | grep -q "no probe path was checked"; then
|
||||
fail "did not report that zero probe paths were checked: $(printf '%s' "$EMPTYTBL_OUT" | tr '\n' ' ')"
|
||||
elif ! printf '%s\n' "$EMPTYTBL_OUT" | grep -q "failed: 1 error(s)"; then
|
||||
fail "an empty probe table failed for reasons beyond the guard: $(printf '%s' "$EMPTYTBL_OUT" | tr '\n' ' ')"
|
||||
else
|
||||
pass "an emptied probe heredoc fails with that guard as the sole error"
|
||||
fi
|
||||
fi
|
||||
|
||||
# --- 5e. Positive: the check does real work against THIS repo ---
|
||||
# Every case above runs against a synthetic fixture, so the whole suite could be
|
||||
# green while the script inspected nothing at all in the repo it is wired into at
|
||||
# pre-push. The summary line carries the counts; assert they are non-zero.
|
||||
#
|
||||
# BOTH counts, not just the probe count. The `.vale.ini` half of that line was a
|
||||
# hardcoded `2` in each branch of the summary — true on any clean run, since a
|
||||
# missing or unreadable file errs out before the summary is reached, but a
|
||||
# constant states what the author expected rather than what the run inspected,
|
||||
# and extracting only the probe count left it asserted by nothing. It is computed
|
||||
# now, so the count is worth reading and worth pinning.
|
||||
echo ""
|
||||
echo "--- reports a non-zero number of inspected targets against this repo ---"
|
||||
REAL_OUT=""
|
||||
REAL_RC=0
|
||||
REAL_OUT="$(bash "$SCRIPT" "$REPO_ROOT" 2>&1)" || REAL_RC=$?
|
||||
REAL_PROBES="$(printf '%s\n' "$REAL_OUT" | sed -n 's/.*checked, \([0-9][0-9]*\) glob probe(s).*/\1/p')"
|
||||
REAL_INIS="$(printf '%s\n' "$REAL_OUT" | sed -n 's/.*: \([0-9][0-9]*\) \.vale\.ini file(s) checked.*/\1/p')"
|
||||
if [[ $REAL_RC -ne 0 ]]; then
|
||||
fail "exited non-zero against this repo's real Vale copies"
|
||||
printf '%s\n' "$REAL_OUT" | sed 's/^/ /'
|
||||
elif [[ -z "$REAL_PROBES" || -z "$REAL_INIS" ]]; then
|
||||
fail "a clean run against this repo reported no inspected-target counts, so 'it checked something' is unverifiable: $(printf '%s' "$REAL_OUT" | tr '\n' ' ')"
|
||||
elif [[ "$REAL_PROBES" -lt 1 ]]; then
|
||||
fail "a clean run against this repo verified $REAL_PROBES glob probes — a pass that inspected nothing"
|
||||
elif [[ "$REAL_INIS" -lt 2 ]]; then
|
||||
fail "a clean run against this repo reported $REAL_INIS .vale.ini file(s) checked — both copies' configs must be inspected"
|
||||
else
|
||||
pass "inspects $REAL_INIS .vale.ini file(s) and $REAL_PROBES glob probe(s) against this repo, and exits 0"
|
||||
fi
|
||||
|
||||
# --- 5b. Exits 1 when REPO_ROOT does not exist ---
|
||||
@@ -527,26 +695,78 @@ else
|
||||
'StylesPath = styles' 'StylesPath = elsewhere'
|
||||
break_glob "$FIXTURE19/plugins/kyberforge/.apm/skills/agent-audit/assets/vale/.vale.ini" \
|
||||
'BasedOnStyles = Kyberforge' 'BasedOnStyles = KyberforgeCopilot'
|
||||
if PATH="$PATH_NO_VALE" bash "$SCRIPT" "$FIXTURE17" > /dev/null 2>&1; then
|
||||
pass "exits 0 on in-sync copies with vale unavailable"
|
||||
# 12a. Missing vale is a HARD FAILURE, not a warning — even on copies that are
|
||||
# otherwise perfectly in sync. It used to be a warning, and a warning made the
|
||||
# six glob probes self-disable on the machine that most needed them: applying
|
||||
# the one-character typo `[**/SKILL.md]` -> `[**/SKILLS.md]` and running with
|
||||
# vale off PATH exited 0, its sole output a stderr line pre-commit swallows,
|
||||
# so the pre-push hook reported `Passed`. That is the exact defect the
|
||||
# glob-coverage section exists to catch, disabled by the absence of the tool
|
||||
# that catches it. Assert the MESSAGE: exit 1 has a dozen causes here and the
|
||||
# fixture is in sync, so the code alone would not distinguish this from any
|
||||
# other finding.
|
||||
NOVALE_OUT=""
|
||||
NOVALE_RC=0
|
||||
NOVALE_OUT="$(PATH="$PATH_NO_VALE" bash "$SCRIPT" "$FIXTURE17" 2>&1)" || NOVALE_RC=$?
|
||||
if [[ $NOVALE_RC -eq 0 ]]; then
|
||||
fail "exited 0 on in-sync copies with vale unavailable — a run that could not verify glob coverage must not report success"
|
||||
elif ! printf '%s\n' "$NOVALE_OUT" | grep -q "vale is not installed, so none of the .vale.ini glob-coverage probes ran"; then
|
||||
fail "failed without vale for the wrong reason — the missing-binary guard did not fire: $(printf '%s' "$NOVALE_OUT" | tr '\n' ' ')"
|
||||
else
|
||||
fail "exited non-zero on in-sync copies with vale unavailable — the missing binary must warn, not fail"
|
||||
pass "hard-fails, saying so, when vale is unavailable and no opt-out is set"
|
||||
fi
|
||||
if PATH="$PATH_NO_VALE" bash "$SCRIPT" "$FIXTURE18" > /dev/null 2>&1; then
|
||||
|
||||
# 12b. The opt-out is the only way to get a clean exit without vale, and it has
|
||||
# to be set deliberately. Absence of the binary must never imply it.
|
||||
if PATH="$PATH_NO_VALE" CHECK_VALE_STYLE_SYNC_ALLOW_MISSING_VALE=1 \
|
||||
bash "$SCRIPT" "$FIXTURE17" > /dev/null 2>&1; then
|
||||
pass "exits 0 on in-sync copies with vale unavailable and the explicit opt-out set"
|
||||
else
|
||||
fail "exited non-zero on in-sync copies with vale unavailable and CHECK_VALE_STYLE_SYNC_ALLOW_MISSING_VALE=1 — the opt-out does not work"
|
||||
fi
|
||||
|
||||
# 12c/12d. The text assertions still gate under the opt-out. This is what the
|
||||
# opt-out has to preserve: masking vale makes the assertion under test the only
|
||||
# thing that can produce the verdict (with vale present, a dropped StylesPath
|
||||
# also breaks the probe, so these cases would still exit 1 with the assertion
|
||||
# itself deleted).
|
||||
if PATH="$PATH_NO_VALE" CHECK_VALE_STYLE_SYNC_ALLOW_MISSING_VALE=1 \
|
||||
bash "$SCRIPT" "$FIXTURE18" > /dev/null 2>&1; then
|
||||
fail "exited 0 on a dropped StylesPath with vale unavailable — expected exit 1"
|
||||
else
|
||||
pass "exits non-zero on a dropped StylesPath with vale unavailable"
|
||||
fi
|
||||
if PATH="$PATH_NO_VALE" bash "$SCRIPT" "$FIXTURE19" > /dev/null 2>&1; then
|
||||
if PATH="$PATH_NO_VALE" CHECK_VALE_STYLE_SYNC_ALLOW_MISSING_VALE=1 \
|
||||
bash "$SCRIPT" "$FIXTURE19" > /dev/null 2>&1; then
|
||||
fail "exited 0 on a BasedOnStyles that dropped Kyberforge with vale unavailable — expected exit 1"
|
||||
else
|
||||
pass "exits non-zero on a BasedOnStyles that dropped Kyberforge with vale unavailable"
|
||||
fi
|
||||
# A clean run without vale must say so — silence would read as verified.
|
||||
if PATH="$PATH_NO_VALE" bash "$SCRIPT" "$FIXTURE17" 2>&1 | grep -q "vale is not installed"; then
|
||||
pass "warns that glob coverage was not verified when vale is unavailable"
|
||||
|
||||
# 12e. An opted-out clean run must still say it verified nothing — otherwise
|
||||
# the opt-out just reintroduces the silent vacuous pass under a new name.
|
||||
OPTOUT_OUT="$(PATH="$PATH_NO_VALE" CHECK_VALE_STYLE_SYNC_ALLOW_MISSING_VALE=1 \
|
||||
bash "$SCRIPT" "$FIXTURE17" 2>&1)"
|
||||
if printf '%s\n' "$OPTOUT_OUT" | grep -q "glob coverage was NOT verified" \
|
||||
&& printf '%s\n' "$OPTOUT_OUT" | grep -q "0 glob probe(s) verified"; then
|
||||
pass "an opted-out clean run reports that glob coverage was not verified"
|
||||
else
|
||||
fail "exited clean without vale and said nothing — an unverified run looks identical to a verified one"
|
||||
fail "an opted-out clean run did not say it verified no glob coverage — it looks identical to a verified one: $(printf '%s' "$OPTOUT_OUT" | tr '\n' ' ')"
|
||||
fi
|
||||
|
||||
# 12f. The typo the whole section exists to catch must fail with vale absent
|
||||
# and the opt-out set, or not at all — never pass. It cannot be caught without
|
||||
# vale, so the opt-out must not turn it into a green run by accident: with the
|
||||
# opt-out this fixture legitimately passes, which is precisely why the opt-out
|
||||
# is gated on an env var and 12a is the default.
|
||||
FIXTURE19B="$(make_fixture)"
|
||||
FIXTURES+=("$FIXTURE19B")
|
||||
break_glob "$FIXTURE19B/plugins/kyberforge/.apm/skills/skill-audit/assets/vale/.vale.ini" \
|
||||
'[**/SKILL.md]' '[**/SKILLS.md]'
|
||||
if PATH="$PATH_NO_VALE" bash "$SCRIPT" "$FIXTURE19B" > /dev/null 2>&1; then
|
||||
fail "the one-character glob typo exited 0 with vale off PATH — the probe self-disabled on the exact defect it exists to catch"
|
||||
else
|
||||
pass "the one-character glob typo does not exit 0 with vale off PATH"
|
||||
fi
|
||||
fi
|
||||
|
||||
|
||||
+246
-1
@@ -12,6 +12,14 @@
|
||||
#
|
||||
# Both branches were code-only and asserted by nothing, which is the same
|
||||
# "green either way" hole the guard itself closes. This file covers them.
|
||||
#
|
||||
# It also covers the aggregation those counts feed, which the parallelization
|
||||
# rewrite left unasserted: three separate mutations of the `if [[ "$file_not_ok"
|
||||
# -gt 0 || "$status" != "0" ]]` line -- dropping either half, or deleting the
|
||||
# whole branch -- all survived this file. They survived because every stub here
|
||||
# exited 0 and emitted no `not ok`, so neither signal was ever exercised, and
|
||||
# because real bats emits both at once each half masks the other. The cases
|
||||
# below produce each signal *without* the other, so each mutation dies alone.
|
||||
set -euo pipefail
|
||||
|
||||
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
@@ -61,11 +69,34 @@ seed_bats_files() {
|
||||
}
|
||||
|
||||
# Runs the fixture's run-bats.sh, capturing output and exit code separately.
|
||||
#
|
||||
# No file-count knob is passed any more, and none is needed: the expected file
|
||||
# set is derived from `git ls-files`, and a mktemp fixture is not a git worktree
|
||||
# root, so run-bats.sh announces that it could not derive an expectation and
|
||||
# falls back to the unconditional zero-file check. Cases 8-8c drive the derived
|
||||
# path deliberately by `git init`-ing their fixtures.
|
||||
#
|
||||
# TMPDIR is a private per-run directory so a stub can find the scratch dir
|
||||
# run-bats.sh mktemp'd for itself -- see case 7.
|
||||
FAKE_OUT=""
|
||||
FAKE_RC=0
|
||||
run_fake() {
|
||||
local priv
|
||||
priv="$(mktemp -d)"
|
||||
FIXTURES+=("$priv")
|
||||
FAKE_RC=0
|
||||
FAKE_OUT="$(bash "$1/tests/run-bats.sh" 2>&1)" || FAKE_RC=$?
|
||||
FAKE_OUT="$(TMPDIR="$priv" bash "$1/tests/run-bats.sh" 2>&1)" || FAKE_RC=$?
|
||||
}
|
||||
|
||||
# A fixture whose root IS a git worktree root, so run-bats.sh derives its
|
||||
# expected set from the index instead of degrading. Only `git add` is used --
|
||||
# `git ls-files` reads the index, so nothing needs committing and no user
|
||||
# identity is required.
|
||||
make_git_fake_repo() {
|
||||
local dir
|
||||
dir="$(make_fake_repo)"
|
||||
git -C "$dir" init -q
|
||||
echo "$dir"
|
||||
}
|
||||
|
||||
# --- 1. A stub emitting nothing at all is a broken harness, not a clean run ---
|
||||
@@ -165,6 +196,220 @@ else
|
||||
fi
|
||||
fi
|
||||
|
||||
# --- 5. `not ok` lines with a zero exit still fail the run. This is the half of
|
||||
# the aggregation that a bats wrapper swallowing the exit status would leave as
|
||||
# the only surviving evidence of a failure, and it is the case that kills the
|
||||
# `drop "$file_not_ok" -gt 0 ||` mutation: without that half the run reports
|
||||
# "2 tests, 1 failures" and exits 0, calling a failing test suite green.
|
||||
echo ""
|
||||
echo "--- a failing test whose process still exits 0 fails the run ---"
|
||||
DIR5="$(make_fake_repo)"
|
||||
FIXTURES+=("$DIR5")
|
||||
seed_bats_files "$DIR5"
|
||||
install_stub_bats "$DIR5" <<'EOF'
|
||||
#!/usr/bin/env bash
|
||||
echo "1..1"
|
||||
echo "not ok 1 a failing test"
|
||||
exit 0
|
||||
EOF
|
||||
run_fake "$DIR5"
|
||||
if [[ $FAKE_RC -eq 0 ]]; then
|
||||
fail "a 'not ok' TAP result exited 0 — a failing test reported as a pass because only the process status was consulted"
|
||||
elif echo "$FAKE_OUT" | grep -q "^2 tests, 2 failures$"; then
|
||||
pass "'not ok' lines fail the run even when every bats process exits 0"
|
||||
else
|
||||
fail "the run failed but with the wrong count: $FAKE_OUT"
|
||||
fi
|
||||
|
||||
# --- 6. A non-zero exit with no `not ok` line still fails the run. This is the
|
||||
# other half: a crash, a timeout, or an unbound variable in setup_file kills bats
|
||||
# before it can emit a result line, so the exit status is the only evidence. It
|
||||
# kills the `drop || "$status" -ne 0` mutation. The stub emits a passing result
|
||||
# first so the zero-count guard cannot be what fails the run -- without the
|
||||
# status half this stub reports "2 tests, 0 failures" and exits 0.
|
||||
echo ""
|
||||
echo "--- a bats exiting non-zero with no 'not ok' line fails the run ---"
|
||||
DIR6="$(make_fake_repo)"
|
||||
FIXTURES+=("$DIR6")
|
||||
seed_bats_files "$DIR6"
|
||||
install_stub_bats "$DIR6" <<'EOF'
|
||||
#!/usr/bin/env bash
|
||||
echo "1..2"
|
||||
echo "ok 1 first"
|
||||
echo "bats: setup_file failed" >&2
|
||||
exit 1
|
||||
EOF
|
||||
run_fake "$DIR6"
|
||||
if [[ $FAKE_RC -eq 0 ]]; then
|
||||
fail "a bats process exiting 1 was reported as a pass because only the TAP text was consulted"
|
||||
elif echo "$FAKE_OUT" | grep -q "^2 tests, 0 failures$"; then
|
||||
pass "a non-zero bats exit fails the run even with no 'not ok' line to find"
|
||||
else
|
||||
fail "the run failed but with the wrong count: $FAKE_OUT"
|
||||
fi
|
||||
|
||||
# --- 7. An empty status file is a failure, not a pass. The status is read back
|
||||
# with `cat ... || echo 1`, which covers a *missing* file; a file that exists but
|
||||
# is empty is what a job killed between the `>` truncating it and the `echo`
|
||||
# completing leaves behind, and what ENOSPC leaves behind. Under the arithmetic
|
||||
# `-ne` that used to compare it, `[[ "" -ne 0 ]]` is false and the job read as a
|
||||
# clean exit.
|
||||
#
|
||||
# The stub reproduces that state exactly: it emits a healthy TAP stream, creates
|
||||
# the empty status file itself, then SIGKILLs the subshell that would have
|
||||
# written the real status. It finds the scratch directory through the private
|
||||
# TMPDIR run_fake sets -- run-bats.sh mktemp -d's under it, and the log file for
|
||||
# job 1 is already open by the time the stub runs.
|
||||
echo ""
|
||||
echo "--- an empty status file fails the run rather than counting as exit 0 ---"
|
||||
DIR7="$(make_fake_repo)"
|
||||
FIXTURES+=("$DIR7")
|
||||
printf '@test "a" { false; }\n' > "$DIR7/tests/a.bats"
|
||||
install_stub_bats "$DIR7" <<'EOF'
|
||||
#!/usr/bin/env bash
|
||||
echo "1..1"
|
||||
echo "ok 1 looked fine"
|
||||
for d in "$TMPDIR"/*/; do
|
||||
if [[ -e "$d/1.log" ]]; then
|
||||
: > "$d/1.status"
|
||||
fi
|
||||
done
|
||||
kill -9 $PPID
|
||||
sleep 5
|
||||
EOF
|
||||
run_fake "$DIR7"
|
||||
# The count line is asserted alongside the exit code so this can only pass for
|
||||
# the stated reason: the TAP stream the stub emitted is healthy, so "1 tests, 0
|
||||
# failures" proves the zero-count guard did not fire and the empty status is the
|
||||
# only thing left that can have failed the run.
|
||||
if ! echo "$FAKE_OUT" | grep -q "^1 tests, 0 failures$"; then
|
||||
fail "the killed job did not leave the healthy TAP stream the case needs: $FAKE_OUT"
|
||||
elif [[ $FAKE_RC -eq 0 ]]; then
|
||||
fail "an empty status file was counted as a clean exit — a killed job reported as a pass"
|
||||
else
|
||||
pass "an empty status file fails the run despite a healthy TAP stream"
|
||||
fi
|
||||
|
||||
# --- 8. A tracked .bats file the walk did not discover is a hard error. This
|
||||
# replaces a `BATS_FILE_FLOOR=8` guess against a real count of 10 -- two files of
|
||||
# slack, which is not hypothetical: deleting two real .bats files left the suite
|
||||
# reporting "155 tests, 0 failures" and exiting 0 with 11 tests silently gone.
|
||||
# The expectation is now derived from `git ls-files`, so it is exact and needs no
|
||||
# magic number.
|
||||
echo ""
|
||||
echo "--- a tracked .bats file missing from the walk fails the run and names it ---"
|
||||
DIR8="$(make_git_fake_repo)"
|
||||
FIXTURES+=("$DIR8")
|
||||
seed_bats_files "$DIR8"
|
||||
install_stub_bats "$DIR8" <<'EOF'
|
||||
#!/usr/bin/env bash
|
||||
echo "1..1"
|
||||
echo "ok 1 first"
|
||||
exit 0
|
||||
EOF
|
||||
git -C "$DIR8" add tests/a.bats tests/b.bats
|
||||
rm "$DIR8/tests/b.bats"
|
||||
run_fake "$DIR8"
|
||||
if [[ $FAKE_RC -eq 0 ]]; then
|
||||
fail "a tracked .bats file gone from the worktree passed — a deleted suite reads as green"
|
||||
elif ! echo "$FAKE_OUT" | grep -q "tracked .bats file(s) were not discovered"; then
|
||||
fail "the run failed but not with the undiscovered-tracked-file message: $FAKE_OUT"
|
||||
elif echo "$FAKE_OUT" | grep -q "^ tests/b.bats$"; then
|
||||
pass "a tracked .bats file missing from the walk fails the run and names the file"
|
||||
else
|
||||
fail "the run failed without naming the missing file: $FAKE_OUT"
|
||||
fi
|
||||
|
||||
# --- 8b. An UNTRACKED .bats file is not a finding. The derived expectation runs
|
||||
# one way only: every tracked file must have been discovered, but a discovered
|
||||
# file need not be tracked. Without this the check would fail on ordinary
|
||||
# not-yet-committed work, which is how a correct guard gets disabled.
|
||||
echo ""
|
||||
echo "--- an untracked new .bats file does not fail the run ---"
|
||||
DIR8B="$(make_git_fake_repo)"
|
||||
FIXTURES+=("$DIR8B")
|
||||
seed_bats_files "$DIR8B"
|
||||
install_stub_bats "$DIR8B" <<'EOF'
|
||||
#!/usr/bin/env bash
|
||||
echo "1..1"
|
||||
echo "ok 1 first"
|
||||
exit 0
|
||||
EOF
|
||||
git -C "$DIR8B" add tests/a.bats
|
||||
run_fake "$DIR8B"
|
||||
if [[ $FAKE_RC -ne 0 ]]; then
|
||||
fail "an untracked .bats file was reported as a finding: $FAKE_OUT"
|
||||
elif echo "$FAKE_OUT" | grep -q "^2 tests, 0 failures$"; then
|
||||
pass "an untracked .bats file is run without being demanded of the index"
|
||||
else
|
||||
fail "the untracked-file run passed with the wrong count: $FAKE_OUT"
|
||||
fi
|
||||
|
||||
# --- 8c. A newly added .bats file joins the expectation immediately. This is the
|
||||
# half a floor can never have: adding files only ever widens a floor's slack,
|
||||
# while `git add` alone makes the new file required from the next run on, with no
|
||||
# edit to this script and no number to bump.
|
||||
echo ""
|
||||
echo "--- a newly git-added .bats file is required from the next run on ---"
|
||||
git -C "$DIR8B" add tests/b.bats
|
||||
rm "$DIR8B/tests/b.bats"
|
||||
run_fake "$DIR8B"
|
||||
if [[ $FAKE_RC -eq 0 ]]; then
|
||||
fail "the .bats file added to the index a moment ago was not demanded back: $FAKE_OUT"
|
||||
elif echo "$FAKE_OUT" | grep -q "^ tests/b.bats$"; then
|
||||
pass "a file added to the index joins the expected set with no floor to bump"
|
||||
else
|
||||
fail "the run failed but did not name the newly tracked file: $FAKE_OUT"
|
||||
fi
|
||||
|
||||
# --- 8d. Outside a git worktree the run still works, and says the expectation
|
||||
# could not be derived. That degradation is what every other fixture here relies
|
||||
# on, and it must be announced rather than silent -- an unannounced fallback is
|
||||
# how a derived check quietly becomes no check at all on a tarball export.
|
||||
echo ""
|
||||
echo "--- a non-git tree runs, and announces that no expectation could be derived ---"
|
||||
DIR8D="$(make_fake_repo)"
|
||||
FIXTURES+=("$DIR8D")
|
||||
seed_bats_files "$DIR8D"
|
||||
install_stub_bats "$DIR8D" <<'EOF'
|
||||
#!/usr/bin/env bash
|
||||
echo "1..1"
|
||||
echo "ok 1 first"
|
||||
exit 0
|
||||
EOF
|
||||
run_fake "$DIR8D"
|
||||
if [[ $FAKE_RC -ne 0 ]]; then
|
||||
fail "a non-git tree failed the run: $FAKE_OUT"
|
||||
elif ! echo "$FAKE_OUT" | grep -q "not a git worktree root"; then
|
||||
fail "a non-git tree silently skipped the derived expectation with no note: $FAKE_OUT"
|
||||
elif echo "$FAKE_OUT" | grep -q "^2 tests, 0 failures$"; then
|
||||
pass "a non-git tree runs the suite and says the expected set could not be derived"
|
||||
else
|
||||
fail "the non-git run passed with the wrong count: $FAKE_OUT"
|
||||
fi
|
||||
|
||||
# --- 9. Zero discovered .bats files is a hard error regardless, and is checked
|
||||
# unconditionally rather than through the derived set: a tree with nothing
|
||||
# tracked at all (a tarball export, a fresh scaffold) must still not run on an
|
||||
# empty set and call it green. It is the state the old `exit 0` branch handled by
|
||||
# name, and the one a path change actually produces.
|
||||
echo ""
|
||||
echo "--- zero discovered .bats files fails the run ---"
|
||||
DIR9="$(make_fake_repo)"
|
||||
FIXTURES+=("$DIR9")
|
||||
install_stub_bats "$DIR9" <<'EOF'
|
||||
#!/usr/bin/env bash
|
||||
exit 0
|
||||
EOF
|
||||
run_fake "$DIR9"
|
||||
if [[ $FAKE_RC -eq 0 ]]; then
|
||||
fail "finding no .bats files at all exited 0 — the whole suite can vanish and the run stays green"
|
||||
elif echo "$FAKE_OUT" | grep -q "found 0 .bats file"; then
|
||||
pass "finding no .bats files fails the run and says so"
|
||||
else
|
||||
fail "the run failed but not with the zero-files message: $FAKE_OUT"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "Results: $PASS passed, $FAIL failed"
|
||||
[[ $FAIL -eq 0 ]]
|
||||
@@ -0,0 +1,529 @@
|
||||
#!/usr/bin/env bash
|
||||
# Regression test for tests/run-tests.sh, the dispatcher pre-push actually
|
||||
# invokes. Nothing tested it at all before this file, and two of the holes that
|
||||
# left open are the same "green either way" defect class the runner one level
|
||||
# down (tests/run-bats.sh) had already been fixed for:
|
||||
#
|
||||
# * run_bats() was `if [[ -x "$BATS" ]]; then ... fi` with no else. Renaming,
|
||||
# moving, or dropping the executable bit off run-bats.sh made the entire bats
|
||||
# suite disappear with no diagnostic while the run printed a green summary and
|
||||
# exited 0, and turned `--bats-only` into a no-op that printed nothing.
|
||||
# * run_bats() then checked only that run-bats.sh was present and executable,
|
||||
# never that it PRODUCED anything. `bash` on an empty run-bats.sh exits 0
|
||||
# having printed nothing, so the dispatcher printed `=== bats ===` and a green
|
||||
# summary. The runner's `N tests, M failures` line is now required, with a
|
||||
# non-zero count.
|
||||
# * The per-script status was compared with `-eq`, which is arithmetic, and bash
|
||||
# evaluates an empty string as 0 there -- so a status file that existed but was
|
||||
# empty counted as a pass.
|
||||
#
|
||||
# The rest of the cases pin behaviour that already worked, so the two fixes above
|
||||
# cannot be "fixed" into a blanket failure: a healthy run is still green, a
|
||||
# non-zero exit is still FAILED, and exit 77 is still SKIPPED rather than either.
|
||||
set -euo pipefail
|
||||
|
||||
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
RUN_TESTS="$REPO_ROOT/tests/run-tests.sh"
|
||||
PASS=0
|
||||
FAIL=0
|
||||
|
||||
pass() { echo " PASS: $1"; PASS=$((PASS + 1)); }
|
||||
fail() { echo " FAIL: $1"; FAIL=$((FAIL + 1)); }
|
||||
|
||||
FIXTURES=()
|
||||
cleanup() { [[ ${#FIXTURES[@]} -eq 0 ]] || rm -rf "${FIXTURES[@]}"; }
|
||||
trap cleanup EXIT
|
||||
|
||||
# Builds a throwaway tree that a copy of run-tests.sh resolves as its own
|
||||
# REPO_ROOT (it derives that from its own location), so these cases drive the real
|
||||
# script with a stub bats runner and a stub set of test-*.sh scripts. The fixtures
|
||||
# live under TMPDIR, never inside the repo, so the real suite cannot pick the case
|
||||
# scripts up as tests of its own.
|
||||
#
|
||||
# Prints the directory and does NOT register it for cleanup -- every caller uses
|
||||
# `$(make_fake_repo)`, so an append made in here would land in the command
|
||||
# substitution's subshell and be lost. Registration is the caller's job. Same
|
||||
# convention as tests/test-run-bats.sh.
|
||||
make_fake_repo() {
|
||||
local dir
|
||||
dir="$(mktemp -d)"
|
||||
mkdir -p "$dir/tests" "$dir/scripts/lib" "$dir/cases"
|
||||
cp "$REPO_ROOT/scripts/lib/batch-run.sh" "$dir/scripts/lib/batch-run.sh"
|
||||
cp "$RUN_TESTS" "$dir/tests/run-tests.sh"
|
||||
echo "$dir"
|
||||
}
|
||||
|
||||
# Writes a stub tests/run-bats.sh from stdin, executable. Every case that is not
|
||||
# about the bats runner installs the healthy one so the bats leg is a constant.
|
||||
install_stub_bats_runner() {
|
||||
cat > "$1/tests/run-bats.sh"
|
||||
chmod +x "$1/tests/run-bats.sh"
|
||||
}
|
||||
install_healthy_bats_runner() {
|
||||
install_stub_bats_runner "$1" <<'EOF'
|
||||
#!/usr/bin/env bash
|
||||
echo "12 tests, 0 failures"
|
||||
exit 0
|
||||
EOF
|
||||
}
|
||||
|
||||
# Writes one case script into the fixture's TEST_DIR. Name must match test-*.sh
|
||||
# or run-tests.sh will not discover it.
|
||||
add_case() {
|
||||
cat > "$1/cases/$2"
|
||||
}
|
||||
|
||||
# Runs the fixture's run-tests.sh over its cases/ directory, capturing output and
|
||||
# exit code separately. TMPDIR is private per run so a case script can locate the
|
||||
# scratch directory run-tests.sh mktemp -d's for itself -- see case 6.
|
||||
FAKE_OUT=""
|
||||
FAKE_RC=0
|
||||
run_fake() {
|
||||
local dir="$1" priv
|
||||
shift
|
||||
priv="$(mktemp -d)"
|
||||
FIXTURES+=("$priv")
|
||||
FAKE_RC=0
|
||||
FAKE_OUT="$(TMPDIR="$priv" TEST_DIR="$dir/cases" bash "$dir/tests/run-tests.sh" "$@" 2>&1)" || FAKE_RC=$?
|
||||
}
|
||||
|
||||
# --- 1. A healthy run is green, runs the bats leg, and says so ---
|
||||
# The control for cases 2 and 3: it proves those fail because the bats runner is
|
||||
# unusable, not because run_bats() now fails unconditionally.
|
||||
echo ""
|
||||
echo "--- a healthy run passes and reports the bats leg ---"
|
||||
DIR1="$(make_fake_repo)"
|
||||
FIXTURES+=("$DIR1")
|
||||
install_healthy_bats_runner "$DIR1"
|
||||
add_case "$DIR1" test-ok.sh <<'EOF'
|
||||
#!/usr/bin/env bash
|
||||
echo "fine"
|
||||
EOF
|
||||
run_fake "$DIR1"
|
||||
if [[ $FAKE_RC -ne 0 ]]; then
|
||||
fail "a healthy fixture failed: $FAKE_OUT"
|
||||
elif ! echo "$FAKE_OUT" | grep -q "^=== bats ===$"; then
|
||||
fail "a healthy run never announced the bats leg: $FAKE_OUT"
|
||||
elif echo "$FAKE_OUT" | grep -q "^=== Summary: 1 passed, 0 skipped, 0 failed ===$"; then
|
||||
pass "a passing case script and a healthy bats runner report 1 passed, 0 failed"
|
||||
else
|
||||
fail "a healthy run produced the wrong summary: $FAKE_OUT"
|
||||
fi
|
||||
|
||||
# --- 2. A non-executable run-bats.sh is a hard error ---
|
||||
# Reproduced on the real repo before the fix: `chmod -x tests/run-bats.sh &&
|
||||
# bash tests/run-tests.sh` printed "Summary: 1 passed, 0 skipped, 0 failed",
|
||||
# exited 0, and never mentioned bats -- 166 tests gone with no diagnostic.
|
||||
echo ""
|
||||
echo "--- a non-executable run-bats.sh fails the run instead of vanishing ---"
|
||||
DIR2="$(make_fake_repo)"
|
||||
FIXTURES+=("$DIR2")
|
||||
install_healthy_bats_runner "$DIR2"
|
||||
chmod -x "$DIR2/tests/run-bats.sh"
|
||||
add_case "$DIR2" test-ok.sh <<'EOF'
|
||||
#!/usr/bin/env bash
|
||||
echo "fine"
|
||||
EOF
|
||||
run_fake "$DIR2"
|
||||
if [[ $FAKE_RC -eq 0 ]]; then
|
||||
fail "a non-executable run-bats.sh exited 0 — the whole bats suite can vanish silently"
|
||||
elif echo "$FAKE_OUT" | grep -q "bats runner not found or not executable"; then
|
||||
pass "a non-executable run-bats.sh fails the run and names what is missing"
|
||||
else
|
||||
fail "the run failed but not with the missing-runner message: $FAKE_OUT"
|
||||
fi
|
||||
|
||||
# --- 3. An absent run-bats.sh is the same hard error ---
|
||||
# The likelier spelling of case 2 in practice: the file is renamed or moved
|
||||
# rather than losing its mode bit.
|
||||
echo ""
|
||||
echo "--- an absent run-bats.sh fails the run ---"
|
||||
DIR3="$(make_fake_repo)"
|
||||
FIXTURES+=("$DIR3")
|
||||
add_case "$DIR3" test-ok.sh <<'EOF'
|
||||
#!/usr/bin/env bash
|
||||
echo "fine"
|
||||
EOF
|
||||
run_fake "$DIR3"
|
||||
if [[ $FAKE_RC -eq 0 ]]; then
|
||||
fail "a missing run-bats.sh exited 0 — a rename deletes the bats suite from the run with no diagnostic"
|
||||
elif echo "$FAKE_OUT" | grep -q "bats runner not found or not executable"; then
|
||||
pass "a missing run-bats.sh fails the run and names what is missing"
|
||||
else
|
||||
fail "the run failed but not with the missing-runner message: $FAKE_OUT"
|
||||
fi
|
||||
|
||||
# --- 4. --bats-only with an unusable runner is a hard error, not a silent no-op ---
|
||||
# This mode has nothing else to run, so the old code path printed nothing at all
|
||||
# and exited 0 -- the single most misleading form of the same bug.
|
||||
echo ""
|
||||
echo "--- --bats-only fails loudly when the runner is missing ---"
|
||||
DIR4="$(make_fake_repo)"
|
||||
FIXTURES+=("$DIR4")
|
||||
add_case "$DIR4" test-ok.sh <<'EOF'
|
||||
#!/usr/bin/env bash
|
||||
echo "fine"
|
||||
EOF
|
||||
run_fake "$DIR4" --bats-only
|
||||
if [[ $FAKE_RC -eq 0 ]]; then
|
||||
fail "--bats-only with no runner exited 0 having printed nothing — a total no-op reported as a pass"
|
||||
elif echo "$FAKE_OUT" | grep -q "bats runner not found or not executable"; then
|
||||
pass "--bats-only fails when the runner is missing rather than doing nothing quietly"
|
||||
else
|
||||
fail "--bats-only failed but not with the missing-runner message: $FAKE_OUT"
|
||||
fi
|
||||
|
||||
# --- 5. A failing bats run propagates ---
|
||||
# run_bats calls the runner under `set -e`, so a red bats suite aborts the whole
|
||||
# dispatcher. Asserted here so that stays deliberate rather than incidental.
|
||||
echo ""
|
||||
echo "--- a failing bats runner fails the whole run ---"
|
||||
DIR5="$(make_fake_repo)"
|
||||
FIXTURES+=("$DIR5")
|
||||
install_stub_bats_runner "$DIR5" <<'EOF'
|
||||
#!/usr/bin/env bash
|
||||
echo "3 tests, 1 failures"
|
||||
exit 1
|
||||
EOF
|
||||
add_case "$DIR5" test-ok.sh <<'EOF'
|
||||
#!/usr/bin/env bash
|
||||
echo "fine"
|
||||
EOF
|
||||
run_fake "$DIR5"
|
||||
if [[ $FAKE_RC -eq 0 ]]; then
|
||||
fail "a bats runner exiting 1 did not fail the dispatcher"
|
||||
else
|
||||
pass "a failing bats runner propagates out of run-tests.sh"
|
||||
fi
|
||||
|
||||
# --- 5b. An EMPTY run-bats.sh is a hard error, not a green no-op ---
|
||||
# Present and executable was still not "it ran". `bash` on a zero-byte script
|
||||
# exits 0 having printed nothing, so the dispatcher printed `=== bats ===`, a
|
||||
# blank line, and `Summary: 1 passed, 0 skipped, 0 failed` with rc=0 -- the whole
|
||||
# bats suite gone, exactly the defect cases 2-4 close for the other spellings.
|
||||
# Truncation, a partial write, an editor saving an empty buffer, and a `set -e`
|
||||
# abort in a run-bats.sh preamble all produce this file.
|
||||
#
|
||||
# It only failed on the real repo incidentally, because tests/test-run-bats.sh
|
||||
# copies run-bats.sh into its own fixtures and blows up there; rename or retire
|
||||
# that file and the hole is live in the gate pre-push invokes. This asserts it
|
||||
# directly.
|
||||
echo ""
|
||||
echo "--- an empty run-bats.sh fails the run instead of passing silently ---"
|
||||
DIR5B="$(make_fake_repo)"
|
||||
FIXTURES+=("$DIR5B")
|
||||
: > "$DIR5B/tests/run-bats.sh"
|
||||
chmod +x "$DIR5B/tests/run-bats.sh"
|
||||
add_case "$DIR5B" test-ok.sh <<'EOF'
|
||||
#!/usr/bin/env bash
|
||||
echo "fine"
|
||||
EOF
|
||||
run_fake "$DIR5B"
|
||||
if echo "$FAKE_OUT" | grep -q "^=== Summary: 1 passed, 0 skipped, 0 failed ===$"; then
|
||||
fail "an empty run-bats.sh produced a green summary — the bats suite vanished with no diagnostic"
|
||||
elif [[ $FAKE_RC -eq 0 ]]; then
|
||||
fail "an empty run-bats.sh exited 0: $FAKE_OUT"
|
||||
elif echo "$FAKE_OUT" | grep -q "without reporting an 'N tests, M failures' summary"; then
|
||||
pass "an empty run-bats.sh fails the run and says the bats suite was never verified"
|
||||
else
|
||||
fail "the run failed but not with the no-summary message: $FAKE_OUT"
|
||||
fi
|
||||
|
||||
# --- 5c. A runner reporting zero tests is a hard error too ---
|
||||
# The other half of "ran but produced nothing": the summary line is there and the
|
||||
# process exits 0, but it accounts for no tests. run-bats.sh has its own guard for
|
||||
# this one file down; asserting it here means the dispatcher does not depend on
|
||||
# that guard surviving, and it pins the count as the thing being read rather than
|
||||
# the mere presence of a line matching the pattern.
|
||||
echo ""
|
||||
echo "--- a bats runner reporting 0 tests fails the run ---"
|
||||
DIR5C="$(make_fake_repo)"
|
||||
FIXTURES+=("$DIR5C")
|
||||
install_stub_bats_runner "$DIR5C" <<'EOF'
|
||||
#!/usr/bin/env bash
|
||||
echo "0 tests, 0 failures"
|
||||
exit 0
|
||||
EOF
|
||||
add_case "$DIR5C" test-ok.sh <<'EOF'
|
||||
#!/usr/bin/env bash
|
||||
echo "fine"
|
||||
EOF
|
||||
run_fake "$DIR5C"
|
||||
if [[ $FAKE_RC -eq 0 ]]; then
|
||||
fail "a bats runner reporting 0 tests exited 0 — a suite that executed nothing read as green: $FAKE_OUT"
|
||||
elif echo "$FAKE_OUT" | grep -q "reported 0 tests"; then
|
||||
pass "a bats runner reporting 0 tests fails the run and says the suite executed nothing"
|
||||
else
|
||||
fail "the run failed but not with the zero-tests message: $FAKE_OUT"
|
||||
fi
|
||||
|
||||
# --- 6. An empty status file is FAILED, not a pass ---
|
||||
# The status is read back with `cat ... || echo 1`, which covers a *missing*
|
||||
# file. A file that exists but is empty is what a job killed between the `>`
|
||||
# truncating it and the `echo` completing leaves behind, and what ENOSPC leaves
|
||||
# behind. Under the arithmetic `-eq` this used to be compared with,
|
||||
# `[[ "" -eq 0 ]]` is true and the job counted as a pass.
|
||||
#
|
||||
# The case script reproduces that state exactly: it truncates its own status file
|
||||
# and then SIGKILLs the subshell that would have written the real one. It finds
|
||||
# the scratch directory through the private TMPDIR run_fake sets -- run-tests.sh
|
||||
# mktemp -d's under it, and with a single case script the index is always 1.
|
||||
echo ""
|
||||
echo "--- an empty status file is reported as FAILED ---"
|
||||
DIR6="$(make_fake_repo)"
|
||||
FIXTURES+=("$DIR6")
|
||||
install_healthy_bats_runner "$DIR6"
|
||||
add_case "$DIR6" test-empty-status.sh <<'EOF'
|
||||
#!/usr/bin/env bash
|
||||
echo "about to be killed mid-write"
|
||||
for d in "$TMPDIR"/*/; do
|
||||
if [[ -e "$d/1.log" ]]; then
|
||||
: > "$d/1.status"
|
||||
fi
|
||||
done
|
||||
kill -9 $PPID
|
||||
sleep 5
|
||||
EOF
|
||||
run_fake "$DIR6"
|
||||
if echo "$FAKE_OUT" | grep -q "^=== Summary: 1 passed, 0 skipped, 0 failed ===$"; then
|
||||
fail "an empty status file counted as a pass — a killed job reads as green"
|
||||
elif [[ $FAKE_RC -eq 0 ]]; then
|
||||
fail "an empty status file did not fail the run: $FAKE_OUT"
|
||||
elif echo "$FAKE_OUT" | grep -q "^=== Summary: 0 passed, 0 skipped, 1 failed ===$"; then
|
||||
pass "an empty status file is counted as FAILED"
|
||||
else
|
||||
fail "an empty status file failed the run with the wrong summary: $FAKE_OUT"
|
||||
fi
|
||||
|
||||
# --- 7. A job killed outright, leaving no status file at all, is FAILED ---
|
||||
# The sibling of case 6 and the path the `|| echo 1` fallback exists for. Both
|
||||
# are asserted because the fix to case 6 must not be a change that only happens
|
||||
# to work when the file is absent.
|
||||
echo ""
|
||||
echo "--- a SIGKILLed job with no status file is reported as FAILED ---"
|
||||
DIR7="$(make_fake_repo)"
|
||||
FIXTURES+=("$DIR7")
|
||||
install_healthy_bats_runner "$DIR7"
|
||||
add_case "$DIR7" test-killed.sh <<'EOF'
|
||||
#!/usr/bin/env bash
|
||||
echo "about to be killed"
|
||||
kill -9 $PPID
|
||||
sleep 5
|
||||
EOF
|
||||
run_fake "$DIR7"
|
||||
if [[ $FAKE_RC -eq 0 ]]; then
|
||||
fail "a SIGKILLed job did not fail the run: $FAKE_OUT"
|
||||
elif echo "$FAKE_OUT" | grep -q "^=== Summary: 0 passed, 0 skipped, 1 failed ===$"; then
|
||||
pass "a job killed with no status file written is counted as FAILED"
|
||||
else
|
||||
fail "a SIGKILLed job failed the run with the wrong summary: $FAKE_OUT"
|
||||
fi
|
||||
|
||||
# --- 8. A case script with a syntax error is FAILED ---
|
||||
# Bash exits 2 on a parse error, which is neither 0 nor the skip code -- the
|
||||
# case that proves the classification is a three-way split and not "0 or not 0".
|
||||
echo ""
|
||||
echo "--- a case script that does not parse is reported as FAILED ---"
|
||||
DIR8="$(make_fake_repo)"
|
||||
FIXTURES+=("$DIR8")
|
||||
install_healthy_bats_runner "$DIR8"
|
||||
add_case "$DIR8" test-syntax.sh <<'EOF'
|
||||
#!/usr/bin/env bash
|
||||
if [ 1 -eq 1 ]; then
|
||||
echo "never closed"
|
||||
EOF
|
||||
run_fake "$DIR8"
|
||||
if [[ $FAKE_RC -eq 0 ]]; then
|
||||
fail "a case script with a syntax error did not fail the run: $FAKE_OUT"
|
||||
elif echo "$FAKE_OUT" | grep -q "^=== Summary: 0 passed, 0 skipped, 1 failed ===$"; then
|
||||
pass "a case script that fails to parse is counted as FAILED"
|
||||
else
|
||||
fail "a syntax error failed the run with the wrong summary: $FAKE_OUT"
|
||||
fi
|
||||
|
||||
# --- 9. exit 1 is FAILED and exit 77 is SKIPPED, in the same run ---
|
||||
# One fixture holding both so the split is asserted against a single summary
|
||||
# line: a skip must not be counted as a pass and must not be counted as a
|
||||
# failure.
|
||||
echo ""
|
||||
echo "--- exit 1 is FAILED and exit 77 is SKIPPED ---"
|
||||
DIR9="$(make_fake_repo)"
|
||||
FIXTURES+=("$DIR9")
|
||||
install_healthy_bats_runner "$DIR9"
|
||||
add_case "$DIR9" test-a-fails.sh <<'EOF'
|
||||
#!/usr/bin/env bash
|
||||
echo "nope"
|
||||
exit 1
|
||||
EOF
|
||||
add_case "$DIR9" test-b-skips.sh <<'EOF'
|
||||
#!/usr/bin/env bash
|
||||
echo "SKIP: a required binary is missing"
|
||||
exit 77
|
||||
EOF
|
||||
add_case "$DIR9" test-c-passes.sh <<'EOF'
|
||||
#!/usr/bin/env bash
|
||||
echo "fine"
|
||||
EOF
|
||||
run_fake "$DIR9"
|
||||
if [[ $FAKE_RC -eq 0 ]]; then
|
||||
fail "a case script exiting 1 did not fail the run: $FAKE_OUT"
|
||||
elif ! echo "$FAKE_OUT" | grep -q "^=== Summary: 1 passed, 1 skipped, 1 failed ===$"; then
|
||||
fail "the pass/skip/fail split was miscounted: $FAKE_OUT"
|
||||
elif ! echo "$FAKE_OUT" | grep -q "^ test-b-skips.sh$"; then
|
||||
fail "the skipped script was not named in the skip list: $FAKE_OUT"
|
||||
elif echo "$FAKE_OUT" | grep -q "^ test-a-fails.sh$"; then
|
||||
pass "exit 1 is FAILED, exit 77 is SKIPPED, and both are named in their lists"
|
||||
else
|
||||
fail "the failed script was not named in the failure list: $FAKE_OUT"
|
||||
fi
|
||||
|
||||
# --- 10. --strict turns a skip into a failure, and names the suite AND the reason ---
|
||||
# Graceful skipping is right for an ad-hoc run and wrong for a gate. At pre-push a
|
||||
# suite exiting 77 means a dependency AGENTS.md documents as required is missing
|
||||
# on the pushing machine -- and pre-commit prints nothing at all for a passing
|
||||
# hook, so the skip list this script writes to stdout was swallowed whole. A
|
||||
# vale-less PATH shipped a green gate having verified 15 of 17 suites.
|
||||
#
|
||||
# The reason is asserted, not just the name: "something was skipped" leaves the
|
||||
# reader with no idea which binary to install, which is most of why the swallowed
|
||||
# list was worth so little in the first place. Matched on the SIX-SPACE INDENT the
|
||||
# report writes, not on the reason text alone -- the suite's own log is echoed
|
||||
# back verbatim earlier in the same output, so a bare text match passes even with
|
||||
# the reason capture deleted. Verified: narrowing the capture to the `SKIP:`
|
||||
# prefix left the loose form green.
|
||||
echo ""
|
||||
echo "--- --strict fails the run on a skipped suite and names it with its reason ---"
|
||||
DIR10="$(make_fake_repo)"
|
||||
FIXTURES+=("$DIR10")
|
||||
install_healthy_bats_runner "$DIR10"
|
||||
add_case "$DIR10" test-needs-a-binary.sh <<'EOF'
|
||||
#!/usr/bin/env bash
|
||||
echo "SKIP: frobnicator is not installed — install it from https://example.invalid"
|
||||
exit 77
|
||||
EOF
|
||||
add_case "$DIR10" test-ok.sh <<'EOF'
|
||||
#!/usr/bin/env bash
|
||||
echo "fine"
|
||||
EOF
|
||||
run_fake "$DIR10" --strict
|
||||
if [[ $FAKE_RC -eq 0 ]]; then
|
||||
fail "--strict passed with a skipped suite — the gate reports green having verified less than it ran: $FAKE_OUT"
|
||||
elif ! echo "$FAKE_OUT" | grep -q "a skip is a SETUP ERROR"; then
|
||||
fail "--strict failed but never said a skip is a setup error: $FAKE_OUT"
|
||||
elif ! echo "$FAKE_OUT" | grep -q "test-needs-a-binary.sh"; then
|
||||
fail "--strict failed without naming the skipped suite: $FAKE_OUT"
|
||||
elif echo "$FAKE_OUT" | grep -q "^ SKIP: frobnicator is not installed"; then
|
||||
pass "--strict fails on a skip, names the suite, and carries through the reason it printed"
|
||||
else
|
||||
fail "--strict named the suite but swallowed its skip reason: $FAKE_OUT"
|
||||
fi
|
||||
|
||||
# --- 10b. RUN_TESTS_STRICT=1 is the same switch. The hook uses the flag because
|
||||
# it is self-documenting in .pre-commit-config.yaml; the env var exists for a CI
|
||||
# runner that cannot edit the command line. Both are asserted so one cannot rot.
|
||||
echo ""
|
||||
echo "--- RUN_TESTS_STRICT=1 fails the run on a skipped suite ---"
|
||||
STRICT_ENV_OUT=""
|
||||
STRICT_ENV_RC=0
|
||||
STRICT_ENV_PRIV="$(mktemp -d)"
|
||||
FIXTURES+=("$STRICT_ENV_PRIV")
|
||||
STRICT_ENV_OUT="$(TMPDIR="$STRICT_ENV_PRIV" TEST_DIR="$DIR10/cases" RUN_TESTS_STRICT=1 \
|
||||
bash "$DIR10/tests/run-tests.sh" 2>&1)" || STRICT_ENV_RC=$?
|
||||
if [[ $STRICT_ENV_RC -eq 0 ]]; then
|
||||
fail "RUN_TESTS_STRICT=1 passed with a skipped suite: $STRICT_ENV_OUT"
|
||||
elif echo "$STRICT_ENV_OUT" | grep -q "a skip is a SETUP ERROR"; then
|
||||
pass "RUN_TESTS_STRICT=1 is the same gate as --strict"
|
||||
else
|
||||
fail "RUN_TESTS_STRICT=1 failed for some other reason: $STRICT_ENV_OUT"
|
||||
fi
|
||||
|
||||
# --- 10c. WITHOUT strict, the same fixture still skips gracefully and passes ---
|
||||
# The control for 10 and 10b, and the half the coordinator asked for explicitly:
|
||||
# an ad-hoc `bash tests/run-tests.sh` on a laptop missing a dev binary must not
|
||||
# go red. Without this, "fix the gate" could quietly mean "fail everywhere".
|
||||
echo ""
|
||||
echo "--- the same skipped suite passes, still SKIPPED, without strict ---"
|
||||
run_fake "$DIR10"
|
||||
if [[ $FAKE_RC -ne 0 ]]; then
|
||||
fail "a skipped suite failed a non-strict run — graceful skipping is gone: $FAKE_OUT"
|
||||
elif ! echo "$FAKE_OUT" | grep -q "^=== Summary: 1 passed, 1 skipped, 0 failed ===$"; then
|
||||
fail "a non-strict run miscounted the skip: $FAKE_OUT"
|
||||
elif echo "$FAKE_OUT" | grep -q "^ SKIP: frobnicator is not installed"; then
|
||||
pass "without strict the suite is SKIPPED, the run passes, and the reason is still reported"
|
||||
else
|
||||
fail "a non-strict run passed but dropped the skip reason: $FAKE_OUT"
|
||||
fi
|
||||
|
||||
# --- 10d. --strict does not become a blanket failure ---
|
||||
# The case that proves 10 and 10b fail for their stated reason. A clean run with
|
||||
# nothing skipped must be just as green under --strict as without it, otherwise
|
||||
# the gate is not a gate, it is an outage.
|
||||
echo ""
|
||||
echo "--- --strict is still green when nothing skipped ---"
|
||||
DIR10D="$(make_fake_repo)"
|
||||
FIXTURES+=("$DIR10D")
|
||||
install_healthy_bats_runner "$DIR10D"
|
||||
add_case "$DIR10D" test-ok.sh <<'EOF'
|
||||
#!/usr/bin/env bash
|
||||
echo "fine"
|
||||
EOF
|
||||
run_fake "$DIR10D" --strict
|
||||
if [[ $FAKE_RC -ne 0 ]]; then
|
||||
fail "--strict failed a run with nothing skipped — it fails unconditionally: $FAKE_OUT"
|
||||
elif echo "$FAKE_OUT" | grep -q "^=== Summary: 1 passed, 0 skipped, 0 failed ===$"; then
|
||||
pass "--strict leaves a run with no skips green"
|
||||
else
|
||||
fail "--strict passed with the wrong summary: $FAKE_OUT"
|
||||
fi
|
||||
|
||||
# --- 10e. An unknown flag is rejected, not ignored ---
|
||||
# `--strict` reaching this script as a silently-ignored argument is the single
|
||||
# typo that turns the gate back off while every hook still reports Passed, so the
|
||||
# arg loop refuses anything it does not know rather than falling through.
|
||||
echo ""
|
||||
echo "--- an unrecognised flag fails with usage instead of being ignored ---"
|
||||
DIR10E="$(make_fake_repo)"
|
||||
FIXTURES+=("$DIR10E")
|
||||
install_healthy_bats_runner "$DIR10E"
|
||||
add_case "$DIR10E" test-ok.sh <<'EOF'
|
||||
#!/usr/bin/env bash
|
||||
echo "fine"
|
||||
EOF
|
||||
run_fake "$DIR10E" --strickt
|
||||
if [[ $FAKE_RC -eq 0 ]]; then
|
||||
fail "a misspelled flag was ignored and the run passed — a typo silently disarms the gate: $FAKE_OUT"
|
||||
elif echo "$FAKE_OUT" | grep -q "Usage: .*--bats-only.*--strict"; then
|
||||
pass "an unrecognised flag fails the run with usage"
|
||||
else
|
||||
fail "an unrecognised flag failed but not with usage: $FAKE_OUT"
|
||||
fi
|
||||
|
||||
# --- 10f. A skip reason printed on STDERR, with no `SKIP:` prefix, still lands ---
|
||||
# There is no house format: three suites print `SKIP: <reason>` on stdout and
|
||||
# tests/test-sync-plugin-content.sh prints `apm not installed -- skipping (...)`
|
||||
# on stderr. batch-run.sh folds stderr into the same log, so both are reachable,
|
||||
# but only a fallback chain finds the second one. Without this case the reason
|
||||
# extraction could be narrowed to the `SKIP:` prefix and the apm suite would fail
|
||||
# the gate with no indication of what to install.
|
||||
echo ""
|
||||
echo "--- a stderr skip reason with no SKIP: prefix is still reported ---"
|
||||
DIR10F="$(make_fake_repo)"
|
||||
FIXTURES+=("$DIR10F")
|
||||
install_healthy_bats_runner "$DIR10F"
|
||||
add_case "$DIR10F" test-stderr-skip.sh <<'EOF'
|
||||
#!/usr/bin/env bash
|
||||
echo "widgetizer not installed -- skipping (see docs)" >&2
|
||||
exit 77
|
||||
EOF
|
||||
run_fake "$DIR10F" --strict
|
||||
if [[ $FAKE_RC -eq 0 ]]; then
|
||||
fail "--strict passed on a suite that skipped via stderr: $FAKE_OUT"
|
||||
elif echo "$FAKE_OUT" | grep -q "^ widgetizer not installed -- skipping"; then
|
||||
pass "a skip reason printed to stderr without a SKIP: prefix is still carried into the failure"
|
||||
else
|
||||
fail "--strict failed but lost the stderr skip reason: $FAKE_OUT"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "Results: $PASS passed, $FAIL failed"
|
||||
[[ $FAIL -eq 0 ]]
|
||||
@@ -255,6 +255,50 @@ else
|
||||
fail "an inherited GIT_DIR/GIT_WORK_TREE redirected the sync outside the fixture"
|
||||
fi
|
||||
|
||||
# --- 13. Outside any git worktree, REPO_ROOT cannot be guessed: hard error ---
|
||||
# `git rev-parse --show-toplevel 2>/dev/null || pwd` used to fall back to $PWD.
|
||||
# Both of this script's exit-0 paths are "the two files agree" or "neither file
|
||||
# exists", so a REPO_ROOT that is not this repo reports "no drift" over a tree it
|
||||
# never inspected — run --check from an empty non-worktree directory and that was
|
||||
# the literal outcome. Case 2 above (a real worktree with no source file) still
|
||||
# exits 0; the difference is whether the tree was identified at all.
|
||||
#
|
||||
# GIT_CEILING_DIRECTORIES rather than trusting `mktemp -d` to land outside a
|
||||
# worktree: TMPDIR may itself sit inside one (the same hazard make_fixture's
|
||||
# header documents), in which case rev-parse would succeed and this case would
|
||||
# quietly test nothing. The ceiling stops git's upward walk at the fixture's
|
||||
# parent, and the precondition below asserts it actually did.
|
||||
echo ""
|
||||
echo "--- outside a git worktree, --check errors instead of guessing \$PWD ---"
|
||||
NOREPO_PARENT="$(mktemp -d)"; track "$NOREPO_PARENT"
|
||||
NOREPO_PARENT="$(cd "$NOREPO_PARENT" && pwd -P)"
|
||||
NOREPO="$NOREPO_PARENT/not-a-worktree"
|
||||
mkdir -p "$NOREPO"
|
||||
if (cd "$NOREPO" && env -u GIT_DIR -u GIT_WORK_TREE GIT_CEILING_DIRECTORIES="$NOREPO_PARENT" \
|
||||
git rev-parse --show-toplevel > /dev/null 2>&1); then
|
||||
fail "precondition: git rev-parse still resolves a worktree under the ceiling — this case would test nothing"
|
||||
else
|
||||
for MODE in "--check" ""; do
|
||||
RC13=0
|
||||
OUT13="$( (cd "$NOREPO" && env -u GIT_DIR -u GIT_WORK_TREE \
|
||||
GIT_CEILING_DIRECTORIES="$NOREPO_PARENT" bash "$SCRIPT" ${MODE:+"$MODE"}) 2>&1 )" || RC13=$?
|
||||
case "$RC13:$OUT13" in
|
||||
0:*)
|
||||
fail "'${MODE:-real sync}' exited 0 outside a git worktree — it reported on a tree it never identified" ;;
|
||||
*"not inside a git worktree"*)
|
||||
pass "'${MODE:-real sync}' errors with a not-a-worktree message instead of falling back to \$PWD" ;;
|
||||
*)
|
||||
fail "'${MODE:-real sync}' failed for an unexpected reason (rc=$RC13): $OUT13" ;;
|
||||
esac
|
||||
done
|
||||
# And it must not have written anything into the directory it refused to trust.
|
||||
if [[ ! -e "$NOREPO/.github" ]]; then
|
||||
pass "nothing is written into the unidentified directory"
|
||||
else
|
||||
fail "the script created files under a directory it could not identify as the repo root"
|
||||
fi
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "Results: $PASS passed, $FAIL failed"
|
||||
[[ $FAIL -eq 0 ]]
|
||||
@@ -301,16 +301,62 @@ else
|
||||
pass "rejects two plugin-dir arguments that share a basename"
|
||||
fi
|
||||
|
||||
# --- 10. Real sync re-injects mcpServers that apm's Copilot builder strips ---
|
||||
# --- 10. Real sync re-injects mcpServers as a PATH that apm's Copilot builder strips ---
|
||||
# The payload is the string ".mcp.json", not the resolved server objects: Copilot's
|
||||
# schema types the field "string or object", and only the string form is incapable
|
||||
# of carrying a credential into a committed, published manifest (see case 23).
|
||||
echo ""
|
||||
echo "--- real sync re-injects mcpServers into .github/plugin/plugin.json ---"
|
||||
echo "--- real sync re-injects mcpServers into .github/plugin/plugin.json as a path ---"
|
||||
FIXTURE10="$(make_fixture_with_mcp '{"mcpServers":{"demo":{"command":"demo-server","type":"stdio"}}}')"; track "$FIXTURE10"
|
||||
bash "$SCRIPT" "$FIXTURE10" > /dev/null 2>&1
|
||||
if jq -e '.mcpServers.demo.command == "demo-server"' "$FIXTURE10/.github/plugin/plugin.json" > /dev/null 2>&1; then
|
||||
pass "mcpServers from .mcp.json is present in .github/plugin/plugin.json after a real sync"
|
||||
if jq -e '.mcpServers == ".mcp.json"' "$FIXTURE10/.github/plugin/plugin.json" > /dev/null 2>&1; then
|
||||
pass "mcpServers is the string \".mcp.json\" in .github/plugin/plugin.json after a real sync"
|
||||
else
|
||||
fail "mcpServers was not re-injected into .github/plugin/plugin.json"
|
||||
fail "mcpServers was not re-injected as the path \".mcp.json\" (got: $(jq -c '.mcpServers // "<absent>"' "$FIXTURE10/.github/plugin/plugin.json" 2>/dev/null))"
|
||||
fi
|
||||
# The inlined-object form is what leaked; assert it is gone, not merely that a
|
||||
# key exists. `jq -e '.mcpServers == ".mcp.json"'` above already implies this, but
|
||||
# a future refactor that emits an object again should fail on the reason, not just
|
||||
# on the shape.
|
||||
if jq -e '.mcpServers | type == "object"' "$FIXTURE10/.github/plugin/plugin.json" > /dev/null 2>&1; then
|
||||
fail "mcpServers was inlined as an object — that is the form that copies .mcp.json verbatim into a published manifest"
|
||||
else
|
||||
pass "mcpServers is not an inlined object"
|
||||
fi
|
||||
|
||||
# --- 10b. The re-injection preserves the manifest's own mode, rather than importing one ---
|
||||
# reinject_mcp_servers used to build its replacement in a `mktemp` file (mode 0600)
|
||||
# and `mv` it over the manifest, carrying 0600 onto a tracked, published file. Git
|
||||
# records only the exec bit, so the demotion survived every commit unnoticed — this
|
||||
# repo's own plugins/bin/.github/plugin/plugin.json really was 0600 on disk while
|
||||
# its five siblings were 0644.
|
||||
#
|
||||
# The fix is `cat "$tmp" >"$dst"`, which keeps the destination inode: the manifest
|
||||
# ends up at whatever mode apm pack gave it a moment earlier, i.e. 0666 & ~umask like
|
||||
# any other freshly created file. So this is asserted across two umasks rather than
|
||||
# against a hardcoded 644 — that is what distinguishes "preserved" from "assigned".
|
||||
# A `mv` of the mktemp yields 600 under both; a `chmod 644` yields 644 under both,
|
||||
# which is the umask dependence that made --check fail on a umask-002 checkout.
|
||||
mode_of() {
|
||||
stat -c '%a' "$1" 2>/dev/null || stat -f '%Lp' "$1" 2>/dev/null
|
||||
}
|
||||
echo ""
|
||||
echo "--- the mcpServers re-injection leaves the manifest at the umask's own file mode ---"
|
||||
for UMASK10B in 022 002; do
|
||||
case "$UMASK10B" in
|
||||
022) EXPECT10B=644 ;;
|
||||
002) EXPECT10B=664 ;;
|
||||
esac
|
||||
FIXTURE10B="$(umask "$UMASK10B"; make_fixture_with_mcp '{"mcpServers":{"demo":{"command":"demo-server","type":"stdio"}}}')"
|
||||
track "$FIXTURE10B"
|
||||
(umask "$UMASK10B"; bash "$SCRIPT" "$FIXTURE10B" > /dev/null 2>&1)
|
||||
MODE10B="$(mode_of "$FIXTURE10B/.github/plugin/plugin.json")"
|
||||
if [[ "$MODE10B" == "$EXPECT10B" ]]; then
|
||||
pass "under umask $UMASK10B the re-injected manifest is $EXPECT10B (its own mode, not mktemp's 600 and not a hardcoded one)"
|
||||
else
|
||||
fail "under umask $UMASK10B the re-injected manifest is $MODE10B, expected $EXPECT10B"
|
||||
fi
|
||||
done
|
||||
|
||||
# --- 11. An empty .mcp.json does not add a redundant mcpServers: {} ---
|
||||
echo ""
|
||||
@@ -479,24 +525,38 @@ echo ""
|
||||
echo "--- --check reports all independent drifts in a single run ---"
|
||||
FIXTURE17="$(make_fixture)"; track "$FIXTURE17"
|
||||
bash "$SCRIPT" "$FIXTURE17" > /dev/null 2>&1
|
||||
printf 'tampered\n' >> "$FIXTURE17/agents/foo.agent.md"
|
||||
printf 'tampered\n' >> "$FIXTURE17/skills/hello/SKILL.md"
|
||||
printf 'tampered\n' >> "$FIXTURE17/commands/mycmd.md"
|
||||
printf 'tampered\n' >> "$FIXTURE17/instructions/style.instructions.md"
|
||||
mkdir -p "$FIXTURE17/hooks"
|
||||
printf '{"hooks": {"PreToolUse": [], "tampered": true}}\n' > "$FIXTURE17/hooks/hooks.json"
|
||||
CHECK17="$(bash "$SCRIPT" --check "$FIXTURE17" 2>&1 || true)"
|
||||
MISSED=""
|
||||
for CATEGORY_PATH in agents skills commands instructions hooks/hooks.json; do
|
||||
# Guarded like case 16's `[[ ! -f ... ]] || continue`, and for the same reason:
|
||||
# an unwritable path here makes `printf >>` fail, and under `set -e` that aborts
|
||||
# the whole script — no "Results:" line, and cases 18-22 never run at all. The
|
||||
# exit status is non-zero so the dispatcher does report FAILED, but the six lost
|
||||
# assertions are invisible and the only diagnostic is a bare shell error.
|
||||
MISSING17=""
|
||||
for CATEGORY_PATH in agents/foo.agent.md skills/hello/SKILL.md commands/mycmd.md \
|
||||
instructions/style.instructions.md; do
|
||||
[[ -f "$FIXTURE17/$CATEGORY_PATH" ]] || MISSING17="$MISSING17 $CATEGORY_PATH"
|
||||
done
|
||||
if [[ -n "$MISSING17" ]]; then
|
||||
fail "sync did not mirror:$MISSING17 — cannot test multi-drift reporting"
|
||||
else
|
||||
printf 'tampered\n' >> "$FIXTURE17/agents/foo.agent.md"
|
||||
printf 'tampered\n' >> "$FIXTURE17/skills/hello/SKILL.md"
|
||||
printf 'tampered\n' >> "$FIXTURE17/commands/mycmd.md"
|
||||
printf 'tampered\n' >> "$FIXTURE17/instructions/style.instructions.md"
|
||||
mkdir -p "$FIXTURE17/hooks"
|
||||
printf '{"hooks": {"PreToolUse": [], "tampered": true}}\n' > "$FIXTURE17/hooks/hooks.json"
|
||||
CHECK17="$(bash "$SCRIPT" --check "$FIXTURE17" 2>&1 || true)"
|
||||
MISSED=""
|
||||
for CATEGORY_PATH in agents skills commands instructions hooks/hooks.json; do
|
||||
case "$CHECK17" in
|
||||
*"DRIFT $FIXTURE17/$CATEGORY_PATH"*) ;;
|
||||
*) MISSED="$MISSED $CATEGORY_PATH" ;;
|
||||
esac
|
||||
done
|
||||
if [[ -z "$MISSED" ]]; then
|
||||
done
|
||||
if [[ -z "$MISSED" ]]; then
|
||||
pass "all five independent drifts are reported in one --check run"
|
||||
else
|
||||
else
|
||||
fail "--check stopped early — never reported drift for:$MISSED"
|
||||
fi
|
||||
fi
|
||||
|
||||
# --- 18. --check sees a mode change on a mirrored executable ---
|
||||
@@ -532,12 +592,17 @@ echo "--- --check detects a mirrored file swapped for a symlink ---"
|
||||
FIXTURE19="$(make_fixture)"; track "$FIXTURE19"
|
||||
bash "$SCRIPT" "$FIXTURE19" > /dev/null 2>&1
|
||||
SYMLINK_TARGET="$FIXTURE19/decoy-agent.md"
|
||||
cp "$FIXTURE19/agents/foo.agent.md" "$SYMLINK_TARGET"
|
||||
rm -f "$FIXTURE19/agents/foo.agent.md"
|
||||
ln -s "$SYMLINK_TARGET" "$FIXTURE19/agents/foo.agent.md"
|
||||
if bash "$SCRIPT" --check "$FIXTURE19" > /dev/null 2>&1; then
|
||||
fail "no drift reported after replacing a mirrored file with a symlink to identical content"
|
||||
# Guarded for the same reason as cases 16 and 17: with the mirror absent the `cp`
|
||||
# below fails and `set -e` takes the rest of the suite down with it.
|
||||
if [[ ! -f "$FIXTURE19/agents/foo.agent.md" ]]; then
|
||||
fail "sync did not mirror agents/foo.agent.md — cannot test the symlink-swap case"
|
||||
else
|
||||
cp "$FIXTURE19/agents/foo.agent.md" "$SYMLINK_TARGET"
|
||||
rm -f "$FIXTURE19/agents/foo.agent.md"
|
||||
ln -s "$SYMLINK_TARGET" "$FIXTURE19/agents/foo.agent.md"
|
||||
if bash "$SCRIPT" --check "$FIXTURE19" > /dev/null 2>&1; then
|
||||
fail "no drift reported after replacing a mirrored file with a symlink to identical content"
|
||||
else
|
||||
pass "a mirrored file replaced by a symlink is detected as drift"
|
||||
bash "$SCRIPT" "$FIXTURE19" > /dev/null 2>&1
|
||||
if [[ -f "$FIXTURE19/agents/foo.agent.md" ]] && [[ ! -L "$FIXTURE19/agents/foo.agent.md" ]]; then
|
||||
@@ -545,6 +610,7 @@ else
|
||||
else
|
||||
fail "re-sync did not restore the symlinked mirror entry to a regular file"
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
# --- 20. .apm/prompts/ is mirrored, via commands/ rather than a prompts/ of its own ---
|
||||
@@ -639,6 +705,587 @@ else
|
||||
fi
|
||||
fi
|
||||
|
||||
# --- 23. A credential in .mcp.json can never reach the published manifest ---
|
||||
# apm's own builder runs _sanitize_mcp_servers() (apm_cli/core/plugin_manifest.py)
|
||||
# before writing .claude-plugin/plugin.json — it drops env/environment/headers/
|
||||
# authorization and any key matching token/secret/password/credential/apikey/key at
|
||||
# any depth, because "copying them verbatim into a committed plugin.json would
|
||||
# exfiltrate them into the distributed artefact". The old jq --slurpfile
|
||||
# re-injection reached .github/plugin/plugin.json by a route that never touched the
|
||||
# sanitizer, so the same fixture produced a stripped Claude manifest and a Copilot
|
||||
# manifest carrying the live token. A path reference cannot carry a secret at all.
|
||||
echo ""
|
||||
echo "--- a token in .mcp.json never reaches .github/plugin/plugin.json ---"
|
||||
# Assembled at runtime, never written as a literal: a credential-shaped constant
|
||||
# in a tracked file is exactly what the gitleaks pre-commit hook exists to reject,
|
||||
# and allowlisting this file to keep one would blunt the scanner across every
|
||||
# future edit to it. The concatenation is what the leak test needs anyway — the
|
||||
# assertion is that this value does not survive into the manifest, and its shape
|
||||
# only has to be distinctive enough to grep for.
|
||||
SECRET="ghp""_TESTONLYnotarealcredential000000000000"
|
||||
FIXTURE23="$(make_fixture_with_mcp "{\"mcpServers\":{\"demo\":{\"command\":\"demo-server\",\"type\":\"stdio\",\"env\":{\"OBSIDIAN_API_TOKEN\":\"$SECRET\"}}}}")"; track "$FIXTURE23"
|
||||
bash "$SCRIPT" "$FIXTURE23" > /dev/null 2>&1
|
||||
if [[ ! -f "$FIXTURE23/.github/plugin/plugin.json" ]]; then
|
||||
fail "sync produced no .github/plugin/plugin.json — cannot test the credential-leak case"
|
||||
else
|
||||
if grep -q "$SECRET" "$FIXTURE23/.github/plugin/plugin.json"; then
|
||||
fail "the .mcp.json token was written into .github/plugin/plugin.json — a tracked, marketplace-distributed file"
|
||||
else
|
||||
pass "no .mcp.json credential appears in the generated Copilot manifest"
|
||||
fi
|
||||
# The Claude-side manifest is apm's own output and is sanitized upstream; assert
|
||||
# it too, so this case fails loudly if a future change starts routing the Claude
|
||||
# manifest through the same re-injection.
|
||||
if [[ -f "$FIXTURE23/.claude-plugin/plugin.json" ]] \
|
||||
&& grep -q "$SECRET" "$FIXTURE23/.claude-plugin/plugin.json"; then
|
||||
fail "the .mcp.json token was written into .claude-plugin/plugin.json"
|
||||
else
|
||||
pass "no .mcp.json credential appears in the generated Claude manifest"
|
||||
fi
|
||||
if bash "$SCRIPT" --check "$FIXTURE23" > /dev/null 2>&1; then
|
||||
pass "--check is clean on a freshly synced fixture whose .mcp.json carries an env block"
|
||||
else
|
||||
fail "--check reports drift on a freshly synced fixture carrying an .mcp.json env block"
|
||||
fi
|
||||
fi
|
||||
|
||||
# --- 24. .mcp.json drift is detected in both directions ---
|
||||
# The re-injection is the only writer of the manifest's mcpServers field, and
|
||||
# nothing covered it: --check could have silently stopped noticing either an
|
||||
# .mcp.json that gained servers or one that lost them.
|
||||
echo ""
|
||||
echo "--- adding servers to .mcp.json after a sync is drift ---"
|
||||
FIXTURE24="$(make_fixture_with_mcp '{"mcpServers":{}}')"; track "$FIXTURE24"
|
||||
bash "$SCRIPT" "$FIXTURE24" > /dev/null 2>&1
|
||||
if jq -e 'has("mcpServers")' "$FIXTURE24/.github/plugin/plugin.json" > /dev/null 2>&1; then
|
||||
fail "an empty .mcp.json produced an mcpServers key — cannot test the gained-servers case"
|
||||
else
|
||||
printf '%s' '{"mcpServers":{"demo":{"command":"demo-server","type":"stdio"}}}' > "$FIXTURE24/.mcp.json"
|
||||
CHECK24="$(bash "$SCRIPT" --check "$FIXTURE24" 2>&1 || true)"
|
||||
case "$CHECK24" in
|
||||
*"DRIFT $FIXTURE24/.github/plugin/plugin.json"*)
|
||||
pass "an .mcp.json that gained its first server is reported as drift" ;;
|
||||
*)
|
||||
fail "no drift reported for .github/plugin/plugin.json after .mcp.json gained a server" ;;
|
||||
esac
|
||||
bash "$SCRIPT" "$FIXTURE24" > /dev/null 2>&1
|
||||
if bash "$SCRIPT" --check "$FIXTURE24" > /dev/null 2>&1; then
|
||||
pass "re-sync clears the gained-server drift"
|
||||
else
|
||||
fail "re-sync did not clear the gained-server drift"
|
||||
fi
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "--- emptying .mcp.json after a sync is drift ---"
|
||||
FIXTURE24B="$(make_fixture_with_mcp '{"mcpServers":{"demo":{"command":"demo-server","type":"stdio"}}}')"; track "$FIXTURE24B"
|
||||
bash "$SCRIPT" "$FIXTURE24B" > /dev/null 2>&1
|
||||
if ! jq -e '.mcpServers == ".mcp.json"' "$FIXTURE24B/.github/plugin/plugin.json" > /dev/null 2>&1; then
|
||||
fail "initial sync did not re-inject mcpServers — cannot test the lost-servers case"
|
||||
else
|
||||
printf '%s' '{"mcpServers":{}}' > "$FIXTURE24B/.mcp.json"
|
||||
CHECK24B="$(bash "$SCRIPT" --check "$FIXTURE24B" 2>&1 || true)"
|
||||
case "$CHECK24B" in
|
||||
*"DRIFT $FIXTURE24B/.github/plugin/plugin.json"*)
|
||||
pass "an .mcp.json emptied of its servers is reported as drift" ;;
|
||||
*)
|
||||
fail "no drift reported for .github/plugin/plugin.json after .mcp.json lost its servers" ;;
|
||||
esac
|
||||
bash "$SCRIPT" "$FIXTURE24B" > /dev/null 2>&1
|
||||
if jq -e 'has("mcpServers") | not' "$FIXTURE24B/.github/plugin/plugin.json" > /dev/null 2>&1 \
|
||||
&& bash "$SCRIPT" --check "$FIXTURE24B" > /dev/null 2>&1; then
|
||||
pass "re-sync drops the mcpServers key and clears the drift"
|
||||
else
|
||||
fail "re-sync did not drop mcpServers / did not clear the lost-server drift"
|
||||
fi
|
||||
fi
|
||||
|
||||
# --- 25. The generated manifests: --check and a real sync agree about their mode ---
|
||||
# The gate's contract is agreement between --check and a real sync, not "every
|
||||
# property is repaired". Neither touches a generated manifest's permission bits, and
|
||||
# neither can: in check mode the expected side is a `cp -a` of the real plugin root,
|
||||
# so apm pack rewrites a file whose mode is already the actual side's. A revision that
|
||||
# listed these paths in the mode manifest was measuring that inheritance, not the
|
||||
# mirror — `chmod 600` left --check at exit 0 for as long as it was listed. This case
|
||||
# pins the agreement instead, so a future "fix" that makes --check report a mode it
|
||||
# cannot repair fails here.
|
||||
echo ""
|
||||
echo "--- --check and a real sync agree that a manifest's mode is not theirs to change ---"
|
||||
FIXTURE25="$(make_fixture_with_mcp '{"mcpServers":{"demo":{"command":"demo-server","type":"stdio"}}}')"; track "$FIXTURE25"
|
||||
bash "$SCRIPT" "$FIXTURE25" > /dev/null 2>&1
|
||||
if ! bash "$SCRIPT" --check "$FIXTURE25" > /dev/null 2>&1; then
|
||||
fail "check reported drift right after the initial sync — cannot test the manifest-mode case"
|
||||
else
|
||||
chmod 600 "$FIXTURE25/.github/plugin/plugin.json"
|
||||
if bash "$SCRIPT" --check "$FIXTURE25" > /dev/null 2>&1; then
|
||||
bash "$SCRIPT" "$FIXTURE25" > /dev/null 2>&1
|
||||
MODE25="$(mode_of "$FIXTURE25/.github/plugin/plugin.json")"
|
||||
if [[ "$MODE25" == "600" ]]; then
|
||||
pass "--check reports no mode drift on a manifest, and a real sync indeed leaves the mode alone"
|
||||
else
|
||||
fail "--check reported no mode drift but a real sync changed the mode to $MODE25 — check and sync disagree"
|
||||
fi
|
||||
else
|
||||
fail "--check reported drift after chmod 600 on .github/plugin/plugin.json, but a real sync cannot repair it — an unfixable pre-push failure"
|
||||
fi
|
||||
fi
|
||||
|
||||
# --- 25b. A manifest replaced by a SYMLINK is real drift and is reported ---
|
||||
# This is the one property of the generated manifests worth asserting, and the reason
|
||||
# it cannot live in check_path_modes: that compares against a `cp -a` of the same
|
||||
# plugin root, which reproduces the symlink on the expected side and calls the two
|
||||
# equal (verified — it sat at exit 0). The hazard is concrete: apm pack opens the
|
||||
# manifest for writing and reinject_mcp_servers redirects into it, and both follow the
|
||||
# link, so a real sync rewrites the link's TARGET instead of the manifest.
|
||||
echo ""
|
||||
echo "--- a plugin.json replaced by a symlink is reported as drift ---"
|
||||
FIXTURE25B="$(make_fixture_with_mcp '{"mcpServers":{"demo":{"command":"demo-server","type":"stdio"}}}')"; track "$FIXTURE25B"
|
||||
bash "$SCRIPT" "$FIXTURE25B" > /dev/null 2>&1
|
||||
if [[ ! -f "$FIXTURE25B/.claude-plugin/plugin.json" ]]; then
|
||||
fail "sync produced no .claude-plugin/plugin.json — cannot test the symlinked-manifest case"
|
||||
else
|
||||
cp "$FIXTURE25B/.claude-plugin/plugin.json" "$FIXTURE25B/decoy.json"
|
||||
rm -f "$FIXTURE25B/.claude-plugin/plugin.json"
|
||||
ln -s ../decoy.json "$FIXTURE25B/.claude-plugin/plugin.json"
|
||||
CHECK25B="$(bash "$SCRIPT" --check "$FIXTURE25B" 2>&1 || true)"
|
||||
case "$CHECK25B" in
|
||||
*"DRIFT $FIXTURE25B/.claude-plugin/plugin.json: is a symlink"*)
|
||||
pass "a symlinked .claude-plugin/plugin.json is reported as drift" ;;
|
||||
*)
|
||||
fail "no drift reported for a symlinked .claude-plugin/plugin.json — a real sync would write through it. Output: $CHECK25B" ;;
|
||||
esac
|
||||
fi
|
||||
|
||||
# --- 26. --check compares full permission bits, not just the exec bit ---
|
||||
# The manifest used to record a bare exec/file kind, so `chmod 444` on a mirrored
|
||||
# SKILL.md left --check at exit 0 while a real sync restored 644 — the same
|
||||
# check/sync disagreement case 18 pins for the exec bit, one bit over.
|
||||
echo ""
|
||||
echo "--- --check detects a non-exec permission change on a mirrored file ---"
|
||||
FIXTURE26="$(make_fixture)"; track "$FIXTURE26"
|
||||
bash "$SCRIPT" "$FIXTURE26" > /dev/null 2>&1
|
||||
if [[ ! -f "$FIXTURE26/skills/hello/SKILL.md" ]]; then
|
||||
fail "sync did not mirror skills/hello/SKILL.md — cannot test the permission-bits case"
|
||||
else
|
||||
chmod 444 "$FIXTURE26/skills/hello/SKILL.md"
|
||||
CHECK26="$(bash "$SCRIPT" --check "$FIXTURE26" 2>&1 || true)"
|
||||
case "$CHECK26" in
|
||||
*"mirrored paths/types/modes differ"*)
|
||||
pass "chmod 444 on a mirrored file is reported as drift" ;;
|
||||
*)
|
||||
fail "no drift reported after chmod 444 on a mirrored file — only the exec bit is being compared" ;;
|
||||
esac
|
||||
bash "$SCRIPT" "$FIXTURE26" > /dev/null 2>&1
|
||||
if bash "$SCRIPT" --check "$FIXTURE26" > /dev/null 2>&1; then
|
||||
pass "re-sync restores the permission bits and clears the drift"
|
||||
else
|
||||
fail "re-sync did not clear the permission-bits drift"
|
||||
fi
|
||||
fi
|
||||
|
||||
# --- 26b. The mode comparison must not depend on the runtime umask ---
|
||||
# Case 26's widening from the exec bit to full permission bits is correct for the
|
||||
# files this script COPIES — both sides of the comparison trace to the same checkout.
|
||||
# It is wrong for the files it WRITES: sync_hooks_json creates hooks/hooks.json with
|
||||
# `printf '%s\n' >`, at the RUNTIME umask, while the real side carries the umask of
|
||||
# the checkout that produced the committed file. Those are independent, so on a
|
||||
# umask-002 machine `--check --all` over this repo's own umask-022 checkout reported
|
||||
# `< file 664 hooks/hooks.json` / `> file 644` for every plugin with hooks — a pre-push
|
||||
# failure with nothing wrong, and unfixable by committing, since git records no
|
||||
# non-exec mode and the next --check from a umask-022 machine fails the other way.
|
||||
#
|
||||
# Two directions, because a one-sided assertion passes on the wrong fix: (a) the same
|
||||
# tree checked under several runtime umasks, and (b) a tree whose GENERATED files carry
|
||||
# a foreign umask — which is exactly what a umask-002 clone of a umask-022 commit looks
|
||||
# like, git having recorded nothing to distinguish them.
|
||||
echo ""
|
||||
echo "--- --check is umask-independent over the files this script generates ---"
|
||||
FIXTURE26B="$(umask 022; make_fixture)"; track "$FIXTURE26B"
|
||||
(umask 022; bash "$SCRIPT" "$FIXTURE26B" > /dev/null 2>&1)
|
||||
if [[ ! -f "$FIXTURE26B/hooks/hooks.json" ]]; then
|
||||
fail "sync did not create hooks/hooks.json — cannot test the umask-independence case"
|
||||
else
|
||||
for UMASK26B in 022 002 077; do
|
||||
if (umask "$UMASK26B"; bash "$SCRIPT" --check "$FIXTURE26B" > /dev/null 2>&1); then
|
||||
pass "--check at umask $UMASK26B is clean on a tree synced at umask 022"
|
||||
else
|
||||
fail "--check at umask $UMASK26B reported drift on a tree synced at umask 022 — the gate is reporting the runner's umask, not the mirror"
|
||||
fi
|
||||
done
|
||||
# What a umask-002 clone of the same commit looks like on disk.
|
||||
chmod 664 "$FIXTURE26B/hooks/hooks.json"
|
||||
[[ -f "$FIXTURE26B/.claude-plugin/plugin.json" ]] && chmod 664 "$FIXTURE26B/.claude-plugin/plugin.json"
|
||||
for UMASK26B in 022 002; do
|
||||
if (umask "$UMASK26B"; bash "$SCRIPT" --check "$FIXTURE26B" > /dev/null 2>&1); then
|
||||
pass "--check at umask $UMASK26B is clean when the generated files carry a umask-002 checkout's mode"
|
||||
else
|
||||
fail "--check at umask $UMASK26B reported drift on generated files carrying a umask-002 checkout's mode — no commit can fix that"
|
||||
fi
|
||||
done
|
||||
fi
|
||||
|
||||
# --- 27. A plugin-dir argument whose basename is . or .. is rejected ---
|
||||
# Every scratch path is "$SCRATCH_ROOT/$(basename "$plugin_dir")", so `..` resolves
|
||||
# to the scratch root's PARENT: `apm pack -o` then writes outside the tree the EXIT
|
||||
# trap cleans, and sync_one's `find "$scratch" -mindepth 1 -maxdepth 1 -type d |
|
||||
# head -1` adopts an arbitrary unrelated directory as the "bundle" — whose contents
|
||||
# a real sync cp -a's into the plugin root after an rm -rf. The duplicate-basename
|
||||
# guard cannot catch it: a single `..` collides with nothing.
|
||||
echo ""
|
||||
echo "--- a plugin dir whose basename is . or .. is rejected ---"
|
||||
for TRAVERSAL in . ..; do
|
||||
RC27=0
|
||||
OUT27="$(bash "$SCRIPT" "$TRAVERSAL" 2>&1)" || RC27=$?
|
||||
case "$RC27:$OUT27" in
|
||||
0:*)
|
||||
fail "'$TRAVERSAL' was accepted as a plugin dir — expected a rejection" ;;
|
||||
*"scratch paths built from it would escape the scratch root"*)
|
||||
pass "'$TRAVERSAL' is rejected with a scratch-path-escape error" ;;
|
||||
*)
|
||||
fail "'$TRAVERSAL' was not rejected with the expected message (rc=$RC27): $OUT27" ;;
|
||||
esac
|
||||
done
|
||||
|
||||
# --- 27b. sync_dir and sync_hooks_json refuse an empty target_dir ---
|
||||
# `set -u` aborts on an UNSET variable but not an empty one, so an empty
|
||||
# $target_dir turns both functions' `rm -rf` calls into `rm -rf /agents`,
|
||||
# `/skills`, `/commands`, `/instructions`, `/extensions`, `/hooks`. Unreachable
|
||||
# from today's two call sites (both pass a validated plugin_dir or a scratch
|
||||
# path), which is exactly why it needs a direct test: no end-to-end invocation
|
||||
# can reach it, and the next caller added is the one that finds out.
|
||||
#
|
||||
# The functions are extracted and run with rm/mkdir/cp/find shadowed by loggers,
|
||||
# so the UNGUARDED form is observed rather than executed — running it for real,
|
||||
# as root, is the outcome the guard exists to prevent.
|
||||
echo ""
|
||||
echo "--- sync_dir/sync_hooks_json abort on an empty target_dir instead of rm -rf'ing / ---"
|
||||
for GUARDED_FN in sync_dir sync_hooks_json; do
|
||||
FNSRC="$(awk -v fn="^${GUARDED_FN}\\\\(\\\\) \\\\{$" '$0 ~ fn, /^\}$/' "$SCRIPT")"
|
||||
if [[ -z "$FNSRC" ]] || [[ "$FNSRC" != *"rm -rf"* ]]; then
|
||||
fail "could not extract $GUARDED_FN() from $SCRIPT — this case is testing nothing"
|
||||
continue
|
||||
fi
|
||||
GUARD_BUNDLE="$(mktemp -d)"; track "$GUARD_BUNDLE"
|
||||
mkdir -p "$GUARD_BUNDLE/agents"
|
||||
printf '{}\n' > "$GUARD_BUNDLE/hooks.json"
|
||||
GUARD_LOG="$(mktemp)"; track "$GUARD_LOG"
|
||||
# HOOKS_DIR_REL/HOOKS_REL/LEGACY_HOOKS_REL are script globals sync_hooks_json
|
||||
# reads; supply them so the extracted copy behaves like the real one.
|
||||
RC27B=0
|
||||
bash -c '
|
||||
set -euo pipefail
|
||||
LOG="$2"
|
||||
HOOKS_DIR_REL="hooks"; HOOKS_REL="hooks/hooks.json"; LEGACY_HOOKS_REL="hooks.json"
|
||||
rm() { printf "rm %s\n" "$*" >> "$LOG"; }
|
||||
mkdir() { printf "mkdir %s\n" "$*" >> "$LOG"; }
|
||||
cp() { printf "cp %s\n" "$*" >> "$LOG"; }
|
||||
find() { printf "find %s\n" "$*" >> "$LOG"; }
|
||||
'"$FNSRC"'
|
||||
'"$GUARDED_FN"' "" "$1" agents
|
||||
' _ "$GUARD_BUNDLE" "$GUARD_LOG" > /dev/null 2>&1 || RC27B=$?
|
||||
DANGEROUS="$(grep -E '(^| )/(agents|skills|commands|instructions|extensions|hooks)([[:space:]]|$)' "$GUARD_LOG" 2>/dev/null || true)"
|
||||
if [[ "$RC27B" -ne 0 ]] && [[ -z "$DANGEROUS" ]]; then
|
||||
pass "$GUARDED_FN aborts on an empty target_dir before touching any path"
|
||||
else
|
||||
fail "$GUARDED_FN with an empty target_dir exited $RC27B and would have run:${DANGEROUS:-<nothing logged>}"
|
||||
fi
|
||||
done
|
||||
|
||||
# --- 28. --all refuses to report success over an unusable or empty marketplace ---
|
||||
# check-plugin-content-sync is the one pre-push gate whose work list comes from a
|
||||
# GENERATED file, so "the marketplace yields nothing" must never mean "verified,
|
||||
# no drift" — regenerating marketplace.json badly would otherwise silence the hook
|
||||
# that guards it. Every case below reached exit 0 before: the process substitution
|
||||
# feeding `while read` is its own subshell, so a jq abort in there yields zero lines
|
||||
# and reads exactly like "declares no local plugins".
|
||||
echo ""
|
||||
echo "--- --check --all fails on a marketplace that yields no plugins ---"
|
||||
make_repo_fixture() {
|
||||
local marketplace_json="$1" dir
|
||||
dir="$(mktemp -d)"
|
||||
dir="$(cd "$dir" && pwd -P)"
|
||||
if ! env -u GIT_DIR -u GIT_WORK_TREE git -C "$dir" init -q >/dev/null 2>&1; then
|
||||
echo "make_repo_fixture: 'git init' failed in $dir" >&2
|
||||
exit 1
|
||||
fi
|
||||
mkdir -p "$dir/.claude-plugin"
|
||||
printf '%s' "$marketplace_json" > "$dir/.claude-plugin/marketplace.json"
|
||||
echo "$dir"
|
||||
}
|
||||
# `env -u GIT_DIR -u GIT_WORK_TREE` for the same reason
|
||||
# tests/test-sync-marketplace-mirror.sh does it: run-tests.sh runs as a pre-push
|
||||
# hook, and git hooks export both variables, which would re-target the script's
|
||||
# `git rev-parse --show-toplevel` at the LIVE repo from any cwd.
|
||||
run_all() {
|
||||
local dir="$1"
|
||||
shift
|
||||
(cd "$dir" && env -u GIT_DIR -u GIT_WORK_TREE bash "$SCRIPT" "$@")
|
||||
}
|
||||
# Message-asserted, not just exit-code-asserted: --all has several independent
|
||||
# routes to exit 1 (missing marketplace, apm pack failure, genuine drift), and an
|
||||
# exit-code-only assertion would pass on any of them.
|
||||
check_all_fails_with() {
|
||||
local desc="$1" marketplace_json="$2" expected="$3" dir out rc=0
|
||||
dir="$(make_repo_fixture "$marketplace_json")"; track "$dir"
|
||||
out="$(run_all "$dir" --check --all 2>&1)" || rc=$?
|
||||
if [[ "$rc" -eq 0 ]]; then
|
||||
fail "$desc: --check --all exited 0 having checked nothing"
|
||||
else
|
||||
case "$out" in
|
||||
*"$expected"*) pass "$desc: rejected with the expected message" ;;
|
||||
*) fail "$desc: exited non-zero but not for the expected reason: $out" ;;
|
||||
esac
|
||||
fi
|
||||
}
|
||||
check_all_fails_with "an empty plugins array" \
|
||||
'{"plugins":[]}' \
|
||||
"declares no local (string-source) plugin entries"
|
||||
check_all_fails_with "a marketplace with no plugins key at all" \
|
||||
'{"name":"x"}' \
|
||||
"declares no local (string-source) plugin entries"
|
||||
check_all_fails_with "unparseable JSON" \
|
||||
'{ not json' \
|
||||
"is not valid JSON"
|
||||
check_all_fails_with "a plugins field that is not an array" \
|
||||
'{"plugins":{"a":1}}' \
|
||||
"expected an array of plugin entries"
|
||||
check_all_fails_with "a JSON array at the marketplace root" \
|
||||
'[]' \
|
||||
"is a JSON array at its top level"
|
||||
check_all_fails_with "an entry with no source field" \
|
||||
'{"plugins":[{"name":"orphan"}]}' \
|
||||
"\`source\` is neither a local path string nor a remote source object"
|
||||
# The guard used to name `.source == null` specifically, so every other malformed
|
||||
# value walked straight through it into the same silence.
|
||||
check_all_fails_with "an entry whose source is a number" \
|
||||
'{"plugins":[{"name":"orphan","source":42}]}' \
|
||||
"\`source\` is neither a local path string nor a remote source object"
|
||||
check_all_fails_with "an entry whose source is an array" \
|
||||
'{"plugins":[{"name":"orphan","source":[]}]}' \
|
||||
"\`source\` is neither a local path string nor a remote source object"
|
||||
|
||||
# --- 28b. --all outside a git worktree refuses instead of guessing $PWD ---
|
||||
# --all's entire work list hangs off REPO_ROOT, so a `|| pwd` fallback lets it derive
|
||||
# that list from a marketplace.json belonging to some other tree. Same reasoning
|
||||
# scripts/sync-marketplace-mirror.sh dropped its own fallback on. Run from a directory
|
||||
# with no marketplace.json the old form happened to hit the "--all requires ..." error,
|
||||
# but only by accident — the dangerous case is a $PWD that HAS one.
|
||||
echo ""
|
||||
echo "--- --all outside a git worktree refuses to guess the repository root ---"
|
||||
NOGIT="$(mktemp -d)"; track "$NOGIT"
|
||||
mkdir -p "$NOGIT/.claude-plugin"
|
||||
printf '%s' '{"plugins":[{"name":"decoy","source":"./plugins/decoy"}]}' > "$NOGIT/.claude-plugin/marketplace.json"
|
||||
if (cd "$NOGIT" && env -u GIT_DIR -u GIT_WORK_TREE git rev-parse --show-toplevel) >/dev/null 2>&1; then
|
||||
fail "fixture precondition: $NOGIT is inside a git worktree, so this case cannot test the no-worktree path"
|
||||
else
|
||||
RC28B=0
|
||||
OUT28B="$(cd "$NOGIT" && env -u GIT_DIR -u GIT_WORK_TREE bash "$SCRIPT" --check --all 2>&1)" || RC28B=$?
|
||||
case "$RC28B:$OUT28B" in
|
||||
0:*)
|
||||
fail "--check --all exited 0 outside a worktree, having derived its plugin list from \$PWD" ;;
|
||||
*"not inside a git worktree"*)
|
||||
pass "--all refuses to guess \$PWD when it cannot locate the repository root" ;;
|
||||
*)
|
||||
fail "--check --all exited $RC28B outside a worktree but not for the stated reason: $OUT28B" ;;
|
||||
esac
|
||||
fi
|
||||
|
||||
# --- 29. A symlink under .apm/ is reported, in both modes ---
|
||||
# apm's bundle exporter filters every symlink out of the bundle it builds
|
||||
# (`f.is_file() and not f.is_symlink()` in _collect_flat/_collect_recursive,
|
||||
# apm_cli/bundle/plugin_exporter.py) with no warning. Every other check here diffs
|
||||
# the live mirror against a freshly synced copy, and BOTH are built from that same
|
||||
# bundle — so the symlink is absent on both sides, they agree, and --check exits 0
|
||||
# while the author's content is simply gone. Not a mismatch: an absence with nothing
|
||||
# left to mismatch against. Verified before the fix: `ln -s real.md link.md` under
|
||||
# .apm/skills/hello/ produced a mirror with no link.md and a --check at exit 0.
|
||||
#
|
||||
# Message-asserted, not exit-code-asserted: an unsynced fixture exits 1 anyway, so a
|
||||
# bare non-zero would pass with the detection deleted.
|
||||
echo ""
|
||||
echo "--- a symlink under .apm/ is reported as lost content in both modes ---"
|
||||
FIXTURE29="$(make_fixture)"; track "$FIXTURE29"
|
||||
printf 'real content\n' > "$FIXTURE29/.apm/skills/hello/real.md"
|
||||
ln -s real.md "$FIXTURE29/.apm/skills/hello/link.md"
|
||||
RCSYNC29=0
|
||||
SYNC29="$(bash "$SCRIPT" "$FIXTURE29" 2>&1)" || RCSYNC29=$?
|
||||
CHECK29="$(bash "$SCRIPT" --check "$FIXTURE29" 2>&1 || true)"
|
||||
for MODE29 in sync check; do
|
||||
case "$MODE29" in
|
||||
sync) OUT29="$SYNC29" ;;
|
||||
check) OUT29="$CHECK29" ;;
|
||||
esac
|
||||
case "$OUT29" in
|
||||
*"$FIXTURE29/.apm/skills/hello/link.md: symlink under .apm/"*)
|
||||
pass "$MODE29 mode reports the symlink under .apm/ by path" ;;
|
||||
*)
|
||||
fail "$MODE29 mode did not report the symlink under .apm/ — apm drops it silently and no diff can see it: $OUT29" ;;
|
||||
esac
|
||||
done
|
||||
# The loss is real, not theoretical: assert the mirror genuinely lacks it, so this
|
||||
# case still means something if apm ever starts exporting symlinks.
|
||||
if [[ ! -e "$FIXTURE29/skills/hello/link.md" ]]; then
|
||||
pass "the symlink is indeed absent from the mirror (nothing else could have caught it)"
|
||||
else
|
||||
fail "the symlink reached the mirror — apm's exporter no longer drops it, so this report is now wrong"
|
||||
fi
|
||||
# The real sync above exited non-zero too (its rc, not a fresh run). Sync and --check
|
||||
# agreeing is this script's core contract, and a real sync that "succeeds" while
|
||||
# dropping content breaks it.
|
||||
if [[ "$RCSYNC29" -ne 0 ]]; then
|
||||
pass "a real sync exits non-zero rather than reporting success over dropped content"
|
||||
else
|
||||
fail "a real sync exited 0 while silently dropping .apm/ content — sync and --check must agree"
|
||||
fi
|
||||
rm -f "$FIXTURE29/.apm/skills/hello/link.md"
|
||||
if bash "$SCRIPT" "$FIXTURE29" > /dev/null 2>&1 && bash "$SCRIPT" --check "$FIXTURE29" > /dev/null 2>&1; then
|
||||
pass "removing the symlink clears the report in both modes"
|
||||
else
|
||||
fail "the symlink report survived its removal"
|
||||
fi
|
||||
|
||||
# --- 29b. The report is scoped to content the mirror would actually carry ---
|
||||
# sync_dir strips <category>/<name>/tests from the mirror outright, so a symlink in
|
||||
# there loses nothing and reporting it would be a false alarm demanding a pointless
|
||||
# edit. A `tests` DEEPER than that is a template asset the mirror does carry (case
|
||||
# 3b), so a symlink in it is real loss. Same depth boundary, both directions —
|
||||
# a carve-out asserted in only one direction passes on "report nothing, ever".
|
||||
echo ""
|
||||
echo "--- the symlink report follows the mirror's own tests/ depth boundary ---"
|
||||
FIXTURE29B="$(make_fixture)"; track "$FIXTURE29B"
|
||||
printf 'x\n' > "$FIXTURE29B/.apm/skills/hello/tests/real.txt"
|
||||
ln -s real.txt "$FIXTURE29B/.apm/skills/hello/tests/link.txt"
|
||||
bash "$SCRIPT" "$FIXTURE29B" > /dev/null 2>&1
|
||||
RC29B=0
|
||||
OUT29B="$(bash "$SCRIPT" --check "$FIXTURE29B" 2>&1)" || RC29B=$?
|
||||
case "$OUT29B" in
|
||||
*"tests/link.txt: symlink under .apm/"*)
|
||||
fail "a symlink under the un-mirrored <name>/tests/ was reported — nothing is lost there" ;;
|
||||
*)
|
||||
pass "a symlink under <name>/tests/ is not reported (that subtree is not mirrored)" ;;
|
||||
esac
|
||||
# Exit code as well as message, from that same run: a carve-out that suppresses the
|
||||
# line but still fails the gate is not a carve-out.
|
||||
if [[ "$RC29B" -eq 0 ]]; then
|
||||
pass "--check is clean with a symlink confined to the un-mirrored tests/ fixture dir"
|
||||
else
|
||||
fail "--check reported drift for a symlink under the un-mirrored <name>/tests/: $OUT29B"
|
||||
fi
|
||||
printf 'y\n' > "$FIXTURE29B/.apm/skills/hello/assets/templates/tests/real.txt"
|
||||
ln -s real.txt "$FIXTURE29B/.apm/skills/hello/assets/templates/tests/link.txt"
|
||||
OUT29B2="$(bash "$SCRIPT" --check "$FIXTURE29B" 2>&1 || true)"
|
||||
case "$OUT29B2" in
|
||||
*"assets/templates/tests/link.txt: symlink under .apm/"*)
|
||||
pass "a symlink under the mirrored assets/templates/tests/ IS reported" ;;
|
||||
*)
|
||||
fail "a symlink under the mirrored assets/templates/tests/ was not reported — the carve-out is depth-agnostic and swallows real loss: $OUT29B2" ;;
|
||||
esac
|
||||
|
||||
# --- 30. --all fails when it verified fewer plugins than the marketplace declares ---
|
||||
# The zero-plugin floor (case 28) rejects "the marketplace yielded nothing"; it cannot
|
||||
# see "it yielded N and only M were checked". sync_one SKIPs a plugin directory with no
|
||||
# .apm/ at status 0, so --all printed one SKIP line and exited 0 having verified one
|
||||
# plugin fewer than it listed — a pre-push gate over a GENERATED work list silently
|
||||
# covering less than it claims.
|
||||
echo ""
|
||||
echo "--- --check --all fails when a listed plugin cannot be verified ---"
|
||||
# A repo fixture with real plugin directories, unlike case 28's marketplace-only one.
|
||||
make_repo_with_plugins() {
|
||||
local dir
|
||||
dir="$(mktemp -d)"
|
||||
dir="$(cd "$dir" && pwd -P)"
|
||||
if ! env -u GIT_DIR -u GIT_WORK_TREE git -C "$dir" init -q >/dev/null 2>&1; then
|
||||
echo "make_repo_with_plugins: 'git init' failed in $dir" >&2
|
||||
exit 1
|
||||
fi
|
||||
mkdir -p "$dir/.claude-plugin" "$dir/plugins"
|
||||
printf '%s' '{"plugins":[{"name":"good","source":"./plugins/good"},{"name":"bare","source":"./plugins/bare"}]}' \
|
||||
> "$dir/.claude-plugin/marketplace.json"
|
||||
local p
|
||||
for p in good bare; do
|
||||
local src
|
||||
src="$(make_fixture)"; track "$src"
|
||||
mv "$src" "$dir/plugins/$p"
|
||||
done
|
||||
echo "$dir"
|
||||
}
|
||||
REPO30="$(make_repo_with_plugins)"; track "$REPO30"
|
||||
# Sync both first, so the ONLY thing --all can complain about below is the count.
|
||||
bash "$SCRIPT" "$REPO30/plugins/good" "$REPO30/plugins/bare" > /dev/null 2>&1
|
||||
if (cd "$REPO30" && env -u GIT_DIR -u GIT_WORK_TREE bash "$SCRIPT" --check --all > /dev/null 2>&1); then
|
||||
pass "--check --all is clean when every declared plugin is verifiable (baseline)"
|
||||
else
|
||||
fail "--check --all reported drift on a freshly synced two-plugin repo — cannot test the count case"
|
||||
fi
|
||||
# Now take one listed plugin's .apm/ away: it is still declared, still on disk, and
|
||||
# now unverifiable. Its mirror is left in place, so no other check has anything to say.
|
||||
rm -rf "$REPO30/plugins/bare/.apm"
|
||||
RC30=0
|
||||
OUT30="$(cd "$REPO30" && env -u GIT_DIR -u GIT_WORK_TREE bash "$SCRIPT" --check --all 2>&1)" || RC30=$?
|
||||
case "$RC30:$OUT30" in
|
||||
0:*)
|
||||
fail "--check --all exited 0 having verified 1 of the 2 plugins its marketplace declares" ;;
|
||||
*"verified 1 of the 2 local plugin entries"*)
|
||||
pass "--check --all fails and names how many of the declared plugins it actually verified" ;;
|
||||
*)
|
||||
fail "--check --all exited $RC30 but not for the under-count reason: $OUT30" ;;
|
||||
esac
|
||||
# The message must name the plugin, not just the arithmetic — a count alone leaves the
|
||||
# reader diffing marketplace.json against a directory listing by hand.
|
||||
case "$OUT30" in
|
||||
*"unverified: $REPO30/plugins/bare"*)
|
||||
pass "the failure names the unverified plugin directory" ;;
|
||||
*)
|
||||
fail "the failure did not name the unverified plugin directory: $OUT30" ;;
|
||||
esac
|
||||
# ...and an explicitly-named plugin dir with no .apm/ stays a skip (case 6): there the
|
||||
# caller chose the work list, so a non-apm directory is their business, not drift in a
|
||||
# generated file.
|
||||
if bash "$SCRIPT" --check "$REPO30/plugins/bare" > /dev/null 2>&1; then
|
||||
pass "the same directory named explicitly is still a clean skip, not a failure"
|
||||
else
|
||||
fail "an explicitly-named plugin dir with no .apm/ now fails — case 6's skip contract is broken"
|
||||
fi
|
||||
|
||||
# --- 31. No `hooks` pointer is injected into the Copilot manifest ---
|
||||
# Copilot types `hooks` "string or object" with NO default (github-copilot-plugins/
|
||||
# configuration.md:47), exactly like mcpServers — so Copilot resolves no hooks from any
|
||||
# plugin here, and re-injecting a pointer the way reinject_mcp_servers() does for
|
||||
# mcpServers looks like the obvious twin fix. It is not, and this case pins the
|
||||
# difference: apm merges .apm/hooks/*.json into exactly ONE hooks.json with no
|
||||
# per-target shaping, while the two ecosystems' hook file formats are mutually
|
||||
# incompatible (Claude: `{"hooks":{"PreToolUse":[{matcher,hooks}]}}`; Copilot:
|
||||
# `{"version":1,"hooks":{"sessionStart":[{type,bash,powershell}]}}`). A pointer would
|
||||
# assert that a Claude-shaped file is Copilot-shaped — a wrong manifest in place of an
|
||||
# incomplete one. .mcp.json carries no such claim: it is one format both hosts read.
|
||||
# See ADR-0017's "no `hooks` pointer" amendment and plugins/kyberforge/docs/hooks.md.
|
||||
echo ""
|
||||
echo "--- the generated Copilot manifest carries no hooks pointer ---"
|
||||
FIXTURE31="$(make_fixture_with_mcp '{"mcpServers":{"demo":{"command":"demo-server","type":"stdio"}}}')"; track "$FIXTURE31"
|
||||
mkdir -p "$FIXTURE31/.apm/hooks"
|
||||
cat > "$FIXTURE31/.apm/hooks/hooks.json" <<'EOF'
|
||||
{"hooks": {"PreToolUse": [{"matcher": "Bash", "hooks": [{"type": "command", "command": "true"}]}]}}
|
||||
EOF
|
||||
bash "$SCRIPT" "$FIXTURE31" > /dev/null 2>&1
|
||||
if [[ ! -f "$FIXTURE31/hooks/hooks.json" ]]; then
|
||||
fail "sync produced no hooks/hooks.json — cannot test the hooks-pointer decision"
|
||||
elif [[ ! -f "$FIXTURE31/.github/plugin/plugin.json" ]]; then
|
||||
fail "sync produced no .github/plugin/plugin.json — cannot test the hooks-pointer decision"
|
||||
else
|
||||
pass "a non-empty .apm/hooks/ produces hooks/hooks.json (Claude Code's convention path)"
|
||||
if jq -e 'has("hooks") | not' "$FIXTURE31/.github/plugin/plugin.json" > /dev/null 2>&1; then
|
||||
pass "no hooks pointer in .github/plugin/plugin.json, even with a real hook present"
|
||||
else
|
||||
fail "a hooks pointer was injected into the Copilot manifest (got: $(jq -c '.hooks' "$FIXTURE31/.github/plugin/plugin.json" 2>/dev/null)) — it would point Copilot at a Claude-shaped hooks file. Reconcile the two hook schemas first; see ADR-0017"
|
||||
fi
|
||||
# The Claude manifest needs none either: hooks/hooks.json IS its convention path.
|
||||
if jq -e 'has("hooks") | not' "$FIXTURE31/.claude-plugin/plugin.json" > /dev/null 2>&1; then
|
||||
pass "no hooks pointer in .claude-plugin/plugin.json either — the convention path needs none"
|
||||
else
|
||||
fail "a hooks pointer appeared in the Claude manifest, which convention-scans hooks/hooks.json already"
|
||||
fi
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "Results: $PASS passed, $FAIL failed"
|
||||
[[ $FAIL -eq 0 ]]
|
||||
Reference in new issue
Block a user