diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index 50246ec..1551e20 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -1,7 +1,7 @@ { "name": "holocron", "description": "AI development skills for Claude Code and GitHub Copilot CLI — factory, design, implement, review, and cross-cutting workflows.", - "version": "0.4.1", + "version": "0.4.2", "owner": { "name": "Defame1297", "email": "defame1297@rkdr.net", @@ -11,7 +11,7 @@ { "name": "kyberforge", "description": "Skills and agents for creating, maintaining, and managing a Claude Code / Copilot CLI plugin marketplace.", - "version": "1.5.0", + "version": "1.6.0", "category": "Developer Tools", "source": "./plugins/kyberforge" }, diff --git a/.claude/settings.json b/.claude/settings.json index deffac9..acf6ed7 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -1,3 +1,16 @@ { - "hooks": {} + "hooks": { + "SessionStart": [ + { + "matcher": "startup", + "hooks": [ + { + "type": "command", + "command": "\"${CLAUDE_PROJECT_DIR}/.claude/hooks/kyberforge/.apm/hooks/check-apm-current.sh\"", + "timeout": 380 + } + ] + } + ] + } } diff --git a/.github/plugin/marketplace.json b/.github/plugin/marketplace.json index 50246ec..1551e20 100644 --- a/.github/plugin/marketplace.json +++ b/.github/plugin/marketplace.json @@ -1,7 +1,7 @@ { "name": "holocron", "description": "AI development skills for Claude Code and GitHub Copilot CLI — factory, design, implement, review, and cross-cutting workflows.", - "version": "0.4.1", + "version": "0.4.2", "owner": { "name": "Defame1297", "email": "defame1297@rkdr.net", @@ -11,7 +11,7 @@ { "name": "kyberforge", "description": "Skills and agents for creating, maintaining, and managing a Claude Code / Copilot CLI plugin marketplace.", - "version": "1.5.0", + "version": "1.6.0", "category": "Developer Tools", "source": "./plugins/kyberforge" }, diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 67a3cf2..8018897 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -37,7 +37,20 @@ repos: # 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)$' + # + # `.claude/settings.json` is the sixteenth, and it is excluded for a + # different reason: apm OWNS that file (ADR-0018, ADR-0019), and + # `apm audit --ci` replays the install into a scratch tree and diffs + # the result byte-for-byte. `pretty-format-json` sorts object keys + # unless `--no-sort-keys` is passed, while apm's hook integrator emits + # insertion order (`matcher` before `hooks`, `type` before `command`). + # Formatting the file therefore rewrites apm's output into a form apm + # would never produce, and the `apm-audit-ci` pre-push hook reports it + # as permanent drift on a file with no git diff -- exactly what + # happened when the SessionStart hook first landed in 2e395a4. + # Re-running `apm install` fixes the file; leaving it in scope here + # would re-break it on the very commit that carries the fix. + exclude: '^(\.claude-plugin/marketplace\.json|\.agents/plugins/marketplace\.json|\.github/plugin/marketplace\.json|plugins/[^/]+/\.claude-plugin/plugin\.json|plugins/[^/]+/\.github/plugin/plugin\.json|\.claude/settings\.json)$' - id: check-yaml stages: ['pre-commit'] - id: trailing-whitespace @@ -255,12 +268,21 @@ repos: - id: skill-size-check stages: ['pre-commit'] - name: SKILL.md size ceiling - description: Enforce agentskills.io's 500-line/5,000-token SKILL.md size ceiling + name: SKILL.md size and context-budget ceilings + description: Enforce agentskills.io's 500-line/2,770-whole-file-word spec ceilings AND ADR-0020's context budget -- description 250 chars SUGGESTION / 400 FAIL, body-only 600 words SUGGESTION / 900 FAIL, and every boundary-clause routing target resolving to a real skill or agent under plugins/*/.apm/ entry: scripts/skill-size-check.sh language: script files: '^plugins/[^/]+/\.apm/skills/[^/]+/SKILL\.md$' pass_filenames: true + verbose: true + # verbose so the SUGGESTION tier is audible. ADR-0020 depends on it: + # "A ceiling does not produce an average ... The halving depends + # entirely on the 250-character SUGGESTION tier being visible and + # respected." pre-commit prints nothing at all for a passing hook, and + # a SUGGESTION deliberately does not fail, so without verbose every + # suggestion would be swallowed -- the exact invisibility ADR-0013 + # records for Vale warnings. Costs nothing on a clean file: the script + # prints only findings. - id: vale-audit-prefilter-skill stages: ['pre-commit'] diff --git a/.pre-commit-hooks.yaml b/.pre-commit-hooks.yaml index 479995a..9031b65 100644 --- a/.pre-commit-hooks.yaml +++ b/.pre-commit-hooks.yaml @@ -13,8 +13,11 @@ files: '(^|/)agents/[^/]+\.md$|\.agent\.md$' - id: kyberforge-skill-size-check - name: SKILL.md size ceiling - description: Enforce agentskills.io's 500-line/5,000-token SKILL.md size ceiling + name: SKILL.md size and context-budget ceilings + description: Enforce agentskills.io's 500-line/2,770-whole-file-word spec ceilings plus ADR-0020's context budget (description 250 chars SUGGESTION / 400 FAIL, body-only 600 words SUGGESTION / 900 FAIL, resolvable boundary-clause routing targets) entry: scripts/skill-size-check.sh language: script files: '(^|/)SKILL\.md$' + # verbose so the SUGGESTION tier reaches a human -- pre-commit prints + # nothing for a passing hook, and a SUGGESTION deliberately does not fail. + verbose: true diff --git a/AGENTS.md b/AGENTS.md index f071c5d..8eaf095 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -38,11 +38,15 @@ Fall back to raw shell only when no skill covers it. ## Setup and testing - Run `apm install` to deploy this repo's own skills and agents into `.claude/skills/` and `.claude/agents/`. Both are gitignored install output, not authoring source — `plugins//.apm/` remains the only place to edit. The six dependencies in root `apm.yml` resolve from the holocron **remote**, unpinned against the default branch, so a `.apm/` edit is not visible to the running session until it is pushed and `apm update` re-runs (`apm install` deploys from `apm.lock.yaml` and does not re-resolve refs). Needs the network, and needs `apm_modules/` (which it materializes) left gitignored. `apm install` also configures the `obsidian` MCP server into the repo's `.mcp.json`, carried over from `plugins/bin/.mcp.json`. -- Do not add repo-owned keys to `.claude/settings.json`. apm treats that file as its own deployed artifact: `apm audit --ci` replays the install into a scratch tree and diffs, so anything apm would not have written there — an `enabledPlugins` block, a real `hooks` entry — is permanent drift that fails the `apm-audit-ci` pre-push hook. Its committed content is whatever apm last wrote — `{"hooks": {}}` until kyberforge's `SessionStart` hook lands there, after which the merged hook entry is apm's output and belongs in the commit (ADR-0019). What does not change is that nothing repo-authored goes in the file. A hook you want in this repo is authored in `plugins//.apm/hooks/` and deployed by apm, never hand-written here. Machine-specific settings go in the gitignored `.claude/settings.local.json`, which apm does not deploy and the replay does not compare; shared enforcement belongs in `.pre-commit-config.yaml`. +- Do not add repo-owned keys to `.claude/settings.json`. apm treats that file as its own deployed artifact: `apm audit --ci` replays the install into a scratch tree and diffs, so anything apm would not have written there — an `enabledPlugins` block, a real `hooks` entry — is permanent drift that fails the `apm-audit-ci` pre-push hook. Its committed content is whatever apm last wrote, which today is the merged `SessionStart` entry for kyberforge's `check-apm-current.sh` — apm's own output, and it belongs in the commit (ADR-0019). What does not change is that nothing repo-authored goes in the file. A hook you want in this repo is authored in `plugins//.apm/hooks/` and deployed by apm, never hand-written here. The file is also **excluded from `pretty-format-json`** in `.pre-commit-config.yaml` — the sixth and last alternation in that `exclude:` pattern, and the only one there for a reason other than "generated manifest". Mind which number you are quoting: six alternations, expanding to sixteen real files (3 root marketplace manifests, 2 per plugin × 6 plugins, plus this one). `pretty-format-json --autofix` sorts object keys while apm emits insertion order, so leaving the file in that hook's scope rewrites apm's output on the way into every commit and `apm audit --ci` then reports permanent drift on a file with an empty `git diff`. Do not tidy it out of that list; it is load-bearing (see `LESSONS.md`, 2026-08-14). Machine-specific settings go in the gitignored `.claude/settings.local.json`, which apm does not deploy and the replay does not compare; shared enforcement belongs in `.pre-commit-config.yaml`. - Keeping the install current is automatic but not free. Because the six dependencies are unpinned, deployed skills go stale whenever anyone merges. kyberforge ships a `SessionStart` hook that runs `apm outdated` at startup (~0.7s) and, when something is behind, runs `apm update --yes` and asks the host to re-scan skills (~10.4s). That rewrites `apm.lock.yaml`, so an unexplained modification to it after opening a session is expected, not a bug — commit or discard it deliberately. Note `apm install` alone will **not** pick up remote changes; it deploys from the lock. `apm update` is the command that re-resolves refs. - Install git hooks via `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 14-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 `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 `python3` — required by `scripts/skill-size-check.sh`, the `skill-size-check` pre-commit hook. It measures the *folded* `description` value: most descriptions here are `>`-block scalars, so a regex over the raw lines measures indentation and newlines instead of the value. Missing it fails the hook with an install pointer rather than skipping the ADR-0020 checks, which would be a vacuous green. In practice it is already present — pre-commit is itself a Python application. **PyYAML is a hard requirement too**, not an optional accelerator: the hand-rolled fallback frontmatter reader has been removed, because a reader that mis-parses an unfamiliar scalar shape reports a clean pass on a file it never measured, which is the exact vacuous-green failure the `python3` check exists to avoid. `pip install pyyaml` if the hook reports it missing. +- That hook enforces **two independent gate families** over `plugins/*/.apm/skills/*/SKILL.md`, and neither replaced the other. The agentskills.io spec backstop is unchanged: 500 lines and 2,770 words, counted over the **whole file including frontmatter**. ADR-0020 adds a context budget measured differently — `description` 250 chars SUGGESTION / 400 FAIL (it is preloaded into every session whether the skill fires or not), **body-only** word count 600 SUGGESTION / 900 FAIL (everything after the frontmatter's closing `---`), a missing, valueless or `null` `description:` (a hard FAIL, not a skip — a gate that declines to measure the one preloaded field reports green), every boundary-clause routing target resolving to a real skill or agent, and every `references/.md` a body names actually existing. Target resolution walks up **from the file being checked** to an authoring root — the nearest ancestor holding `plugins/*/.apm/{skills,agents}`, falling back to the nearest `.git`, in two passes so a nested `.git` cannot beat a real monorepo root. The universe is then every skill and agent under `/plugins/*/`, plus the checked file's own apm package and whatever that package declares in its own `apm.yml` `dependencies.apm`; the **root** manifest's `dependencies:` block is not read, and no plugin here declares a cross-plugin apm dependency. Deployed `.claude/`/`.agents/` trees are consulted only when the walk found no plugin monorepo root — whether it landed on a bare `.git` ancestor or on nothing at all (the consumer case). The gate keys on which of the two passes matched, not on whether the root contributed any new name: a single-plugin monorepo re-collects its own package and adds nothing, so a name-count test reads zero there and would drag the deployed trees back into the universe. That matters because those trees are gitignored `apm install` output: resolution used to reach the four cross-plugin `gitea-*` → `git-*` targets through `.claude/skills/` alone, so the same commit measured 2 dangling targets on a developer machine and 6 on a fresh clone. It no longer does — verified by running the hook over a tree holding only `plugins/` and the root `apm.yml`, which reports findings identical to the working tree (26 description / 9 body / 2 dangling / 0 missing references / 58 SUGGESTIONs). Three further checks are SUGGESTION-only: a description with no boundary clause at all, a `## Gotchas` section with more than five entries, and a `## Gotchas` section over 25% of the body. A file can sit well inside one family and fail the other. The hook is `verbose: true` so the SUGGESTION tier is audible — pre-commit prints nothing at all for a passing hook, and a SUGGESTION deliberately does not fail. `skill-audit`'s `validate.sh` holds a second copy of the four ADR-0020 constants; `tests/test-skill-size-check.sh` asserts the copies agree. +- **Those ADR-0020 gates ship hot, with no baseline file.** 26 of 39 descriptions and 9 of 39 bodies currently exceed their FAIL tier, so editing one of those skills *for any reason* means retrofitting it to the contract first — a one-line fix to `gitea-prs` cannot be committed until that skill complies. This is deliberate, and the retrofit is tracked as Gitea issue #99. Check where a skill stands before starting: `pre-commit run skill-size-check --all-files`. +- **A second gate ships hot alongside it, and `skill-size-check` will not warn you about it.** `Kyberforge.CompositionNote` — the ADR-0020 Vale rule banning composition and architecture prose from a description — currently fires **10 errors across four skills**: `gitea-issues`, `gitea-labels-milestones`, `gitea-prs` and `gitea-workflow`. Every Vale rule here is `level: error` with no ignorable tier, so touching any of those four means fixing its prose findings as well as its size findings. Scoping a retrofit off `skill-size-check` output alone will leave you blocked at the second gate. Check both: `pre-commit run --all-files`. - 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. @@ -50,7 +54,7 @@ Fall back to raw shell only when no skill covers it. - `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. Both discovery walks (`tests/run-bats.sh` and `tests/run-tests.sh`) exclude `apm_modules/`: `apm install` materializes a full copy of every plugin there, and running a dependency's copy of a `.bats` file breaks its relative path to the bats helpers — 167 spurious failures before the exclusion landed. - Pushing runs 14 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`, `check-executables-allow-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`. `check-executables-allow-sync` is the odd one in that first group — it guards a silent failure rather than drift in generated text. apm gates a package's `hooks/` and `bin/` on an exact `#` lookup in root `apm.yml`'s `executables.allow`, with no wildcard and no version-less form, so bumping `plugins/kyberforge/apm.yml`'s `version:` without bumping the key errors nowhere: the entry simply stops matching, kyberforge's `SessionStart` hook stops deploying, and the install goes quietly stale — the failure ADR-0019 records as live. Run `pre-commit run --hook-stage pre-push --all-files` locally — one command, the whole gate. That command reports **16**, not 14: 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. +- `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. Agents take the ADR-0020 description gates (`agent-audit`'s `validate.sh` holds its own copy of those two constants) and, deliberately, **no** body word gate: an agent body becomes the system prompt of a fresh context rather than competing with the caller's live conversation, so the 900-word FAIL does not transfer. A bats test pins that absence in `agent-audit`'s validator — adding a body gate there contradicts the ADR rather than fixing an inconsistency. Be precise about the scope of that guarantee, though: it holds for the **validator**, not for the shared script. `scripts/skill-size-check.sh` applies its body gate to whatever path it is handed, and `bash scripts/skill-size-check.sh plugins/*/.apm/agents/*.agent.md` exits 1 today with 900-word body FAILs on `git-orchestrate` (933), `gitea-orchestrate` (1,199) and `apm-orchestrate` (1,080). Agent files escape only because the hook definitions filter on `SKILL.md` — a file-pattern accident that happens to implement the design, not the design itself. Do not "extend" that hook's `files:` pattern to cover agents on the assumption that the script already knows the difference. - **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 twelve pre-push hooks pass offline because they are real local checks (`check-executables-allow-sync` landed after that run, but reads two local manifests and makes no network call), 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-commits` — it validates Conventional Commits (enforced at `commit-msg`) for you. diff --git a/CONTEXT.md b/CONTEXT.md index fa44721..2a0dc12 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -26,6 +26,21 @@ A separate product (separate repo) for browsing, editing, and configuring AI dev ### Skills Reusable slash commands for AI coding tools, defined as `SKILL.md` files following the [Agent Skills open standard](https://agentskills.io). Authored at `plugins//.apm/skills//SKILL.md` and reaching a host by one of two install paths: `apm install`, which deploys the skill directory to `.claude/skills//` (this repo's own path — see "apm-consumed install"), or `claude plugin install @`, which caches the whole plugin (still supported for external consumers). Skills are self-contained — they cannot reference files outside the plugin directory after install-time caching. The two paths name skills differently: apm deploys a plain project skill (`skill-audit`), a plugin install namespaces it (`kyberforge:skill-audit`). +### Preload tax +The always-on context cost of every installed skill's `name` + `description`, which sit in the agent's context from the first token of every session whether or not the skill is invoked. Measured 2026-08-14 against base commit `f9b919d` at 23,427 chars (~5,900 tokens) across 39 skills, plus 1,325 chars for 4 agents. Method, so it can be re-run: sum `len(name) + len(description)` over each `plugins/*/.apm/skills/*/SKILL.md` frontmatter with `>` block scalars folded to the value the host loads, at ~4 characters per token. Non-routing frontmatter (`metadata.source_keys`, `category`, `version`) is **not** part of it — the model-visible skill listing carries only `name` and `description`, which supersedes `LESSONS.md:63` on this host. Bodies are not part of it either; they are charged on invocation. + +### Skill context contract +The authoring rules that hold the preload tax and body size down, set by ADR-0020. A description carries a trigger clause, at most one capability clause, and a boundary clause of the form `Not → ` naming a resolvable target — nothing else. "Resolvable" is decided by walking up *from the file being checked* to an **authoring root** — the nearest ancestor holding `plugins/*/.apm/{skills,agents}`, falling back to the nearest `.git`, in two passes so a nested `.git` cannot outrank a real monorepo root. The universe is then every skill and agent under `/plugins/*/` (sibling plugins resolve against each other, which is what a monorepo means), plus the checked file's own apm package and that package's own declared `dependencies.apm`. The **root** manifest's dependency list is never consulted, and no plugin here declares a cross-plugin apm dependency. Deployed `.claude/`/`.agents/` trees count only when there is no authoring root at all — the consumer case. The property this buys is that one commit gets one verdict: those trees are gitignored `apm install` output, so resolving through them made the same commit report 2 dangling targets on a developer machine and 6 on a fresh clone, which a gate shipping hot with no baseline cannot do. A `${BASH_SOURCE}`-relative repo root is the other half of the same defect and is gone — it leaked this repo's 39-skill universe into consumer repos running the hook through pre-commit. A *missing* boundary clause is a SUGGESTION rather than a failure, for skills and agents alike — some skills genuinely have no near-miss sibling. A *missing or empty description* is the opposite: a hard FAIL in all three validators, because a gate that merely declines to measure the one preloaded field reports green. Capability enumeration, output formats, and composition notes ("composes X rather than duplicating Y") belong in the body or `README.md`; a description that summarises workflow is a correctness hazard, not just a cost, because agents act on it instead of reading the body. Sizes are two-tier and sit *below* the agentskills.io spec limits, which stay unchanged as conformance backstops: description 250 SUGGESTION / 400 FAIL (spec 1,024); body 600 SUGGESTION / 900 FAIL (spec 2,770 words / 500 lines). Conflating the quality gate with the spec ceiling is what let `skill-author` and `agent-author` grow to within twelve words of 2,770. + +### Dispatch body +The body pattern a skill with two or more mutually exclusive flows must use: the body carries only the dispatch table and the gates common to every branch, and each flow lives in its own self-contained `references/` file. Named for `apm-workflow` (421-word body, 3,006 words of references), which arrived at it independently and is the repo's exemplar. Its absence was the characteristic defect at the time ADR-0020 was written: `skill-author` inlined both its create and improve flows, and `agent-author` carried 50-60 lines marked inapplicable by their own headers on any single run. Both were retrofitted to dispatch tables in the change that carries the ADR — `skill-author` went 2,623 body words to 595 and `agent-author` 2,582 to 616 — so they are now worked examples of the pattern rather than counter-examples of it. The 39-skill corpus at large is not: 9 bodies still exceed the 900-word FAIL (issue #99). + +### Hand-invoked skill +A skill reached only by typing its slash command, declared with `disable-model-invocation: true`. The host withholds it from the model-visible skill listing entirely, so it pays no preload tax and its `description` becomes human-facing text rather than a trigger list. `zoom-out` is the worked example: apm passes the flag through verbatim to both install paths, and the skill is absent from the router while `/zoom-out` still works. Choosing model-invoked vs. hand-invoked is the first question `skill-author` asks, because it determines whether a description needs triggers at all. + +### Delegation discipline +The agent-side counterpart to the dispatch body. A plugin-scope agent is a single `.apm/agents/.agent.md` file with no sibling `references/` directory, so it cannot disclose to itself — it can only delegate to skills. Its characteristic defect is therefore restatement, not length: an agent body that spells out a procedure a skill it can invoke already owns creates a second copy that drifts. `agent-audit` fails that, with the fix being "invoke `` instead". Agents take the same description gates as skills but no body word gate — a skill body competes with the caller's live conversation, an agent body becomes the system prompt of a fresh context. + ### Plugin The deployable unit in the plugin marketplace. A plugin bundles one or more skills, agents, hooks, prompts, MCP servers, and optionally a `bin/` directory into a single installable directory. In this repo, plugins live under `plugins//`, each with its own `apm.yml` + `.apm/{skills,agents,hooks,...}` — this is the authoring source of truth for the plugin's content (ADR-0015). Two categories of tracked output are compiled from that source, never hand-edited: `.claude-plugin/plugin.json` (Claude Code) and `.github/plugin/plugin.json` (Copilot CLI) via `apm pack`/`apm compile`; and, alongside them, a flat `agents/`, `skills/`, `commands/`, `instructions/`, `extensions/` directory mirror at the plugin root plus a merged hooks file at `hooks/hooks.json`, generated by `scripts/sync-plugin-content.sh` — Claude Code's and Copilot's installers convention-scan only these flat paths (`hooks/hooks.json` is the convention path for hooks specifically; a root-level `hooks.json` is scanned by nothing and is deleted as stale by a sync — see ADR-0017's 2026-08-14 amendment) and have no awareness of `.apm/` nesting at all, so this mirror is what actually makes `.apm/` content discoverable at install time (ADR-0017). Plugins are copied to a cache on install — they cannot reference files outside their own directory. Install a plugin with `claude plugin install @`, or consume it as an apm dependency (see "apm-consumed install"). @@ -85,7 +100,7 @@ Wiring Vale as a deterministic prefilter for `skill-audit`/`agent-audit`'s Descr Both skills' Step 1, and the `vale-audit-prefilter-skill`/`-agent` pre-commit hooks, call each copy's own `scripts/vale-wrap.sh` rather than `vale` directly — a workaround for a confirmed Vale 3.15.2 limitation (see `vale-config`'s Gotchas): `text.frontmatter.description` silently stops matching on most — not all — multi-line descriptions. Verified by reproduction, not assumed: `>` folded scalars, plain (unquoted) continuation lines, and single- or double-quoted multi-line scalars all yield 0 alerts and exit 0 on a deliberately-bad fixture, while a `|` literal block spanning the same 2+ lines lints normally (alerts fire, exit 1). The wrapper flattens those three broken forms to one physical line in a scratch copy (padding with blank lines so every other line number is unchanged) before handing off to real `vale`; `|` literal blocks and single-line descriptions pass through untouched, already linting correctly. The plain and quoted forms previously passed silently — unflattened and unmatched — so a bad description in either sailed through the prefilter. Handed no `--config` at all, the wrapper falls back to its own sibling `assets/vale/.vale.ini`, located from `${BASH_SOURCE[0]}` rather than from the cwd — which is why both manifests' `entry:` is now the bare script path with no argument after it. pre-commit prefixes only `entry[0]` with the hook-repo clone path (`cmd = (prefix.path(cmd[0]), *cmd[1:])`), so every later argument resolves against the *consuming* repo's root: a `--config` in `.pre-commit-hooks.yaml` pointed at a path no consumer has and hard-failed every external run with `E100 [--config] Runtime error`. `.pre-commit-config.yaml` drops the argument too, deliberately keeping the two entries identical — the local `repo: local` hook resolved its `--config` correctly only because the consuming repo *was* this repo, and that divergence is why three review rounds exercised a path no external consumer takes and missed the defect. An explicit `--config` still wins, in all three argv forms (`--config X`, `--config=/abs`, `--config=rel`), and a relative one still resolves against the caller's cwd, matching bare `vale`, not the repo root. Both audit skills' Step 1 now passes no `--config` either: it resolves the script relative to the skill's own directory so the call works from an installed plugin cache, but a relative `--config` alongside it would still resolve against the cwd, yielding `E100 Runtime error ... does not exist` and exit 2 — which both skills' fallback misreads as "vale unavailable" and silently downgrades to full LLM judgment. `tests/test-vale-wrap.sh` regression-tests this against skill-audit's copy specifically (its fixtures are all `SKILL.md`-shaped, and only skill-audit's `.vale.ini` has that glob section). Each `.vale.ini`'s section globs are path-agnostic (`[**/SKILL.md]` for skill-audit's copy; `[**/agents/*.md]`/`[**/*.agent.md]` for agent-audit's) and do no scoping on their own: Vale's `*` crosses `/`. Scoping comes from each pre-commit hook's own `files:` regex and from the audit skills passing one explicit file per invocation. The two manifests scope differently on purpose: this repo's `.pre-commit-config.yaml` pins its own layout — `^plugins/[^/]+/\.apm/skills/[^/]+/SKILL\.md$` for `-skill`, `^plugins/[^/]+/\.apm/agents/[^/]+\.agent\.md$` for `-agent` — while the shipped `.pre-commit-hooks.yaml` stays layout-agnostic for external consumers whose skills live anywhere, using `(^|/)SKILL\.md$` and `(^|/)agents/[^/]+\.md$|\.agent\.md$`. Both manifests split the prefilter into two hooks precisely because one combined hook pointed at only one copy would silently 0-file-skip the other file type. A `SKILL.md` outside `plugins/` (e.g. project-scope `.claude/skills/foo/SKILL.md`) still matches `[**/SKILL.md]` and gets linted normally — the globs constrain filename shape, not location. Vale reports 0 files only when the path it is handed matches no glob section at all: a differently-named file, or a directory argument holding nothing that matches. That run prints `✔ 0 errors ... in 0 files.` and exits 0, indistinguishable from a clean pass, so both audits treat a 0-file Vale run as NOT RUN and fall back to full LLM judgment. -This scope expands per ADR-0013: one cherry-picked low-noise `write-good`/`alex` rule landed in `styles/Kyberforge`, `Kyberforge.SentenceOpenerThereIs` (22 held-out hits, both in-corpus hits clean rewrites, zero suppressions). A second, `Kyberforge.VagueQualifier`, was cherry-picked and then deleted: 2 hits across the 41 skill/agent files, one marginal and one an unfixable false positive (`caveman/SKILL.md` quotes `of course` as an example of filler — a mention, not a use) that forced the repo's only Vale suppression comments. Also new is a sibling pre-commit hook, `skill-size-check` (`scripts/skill-size-check.sh`), enforcing agentskills.io's `SKILL.md` ceiling as two blocking gates: `MAX_LINES=500` and `MAX_WORDS=2770` (a word-count proxy for the 5,000-token limit, calibrated to the densest prose measured in this repo — 1.81 tokens per word — so even a worst-case `SKILL.md` at the ceiling stays under 5,000 tokens). Both are inclusive, and `skill-audit/scripts/validate.sh` checks the same pair on the same terms, so a `SKILL.md` can no longer pass its own audit yet be blocked by the commit hook. Scoped to `^plugins/[^/]+/\.apm/skills/[^/]+/SKILL\.md$` only, same as `vale-audit-prefilter-skill`, so it never lints `docs/research/examples/` reference skills. It's also exposed in the root-level `.pre-commit-hooks.yaml` as `kyberforge-skill-size-check` — it has no external asset dependency, so it needed no relocation, only exposure to external consumers. File scope (`SKILL.md` + agent files) and enforcement model (rules land directly in `styles/Kyberforge`, blocking immediately, no trial tier) stay unchanged; governance.md/CONTROLS.md were evaluated and excluded as rule sources (nothing prose-pattern-matchable to mine). House convention: banned phrasing that must be mentioned rather than used goes in backticks or a fenced code block — Vale skips code spans and fences, so no suppression is needed; inline `` (HTML-comment form; the MDX `{/* */}` form does not work in plain Markdown) is the fallback only where backticking is impossible. +This scope expands per ADR-0013: one cherry-picked low-noise `write-good`/`alex` rule landed in `styles/Kyberforge`, `Kyberforge.SentenceOpenerThereIs` (22 held-out hits, both in-corpus hits clean rewrites, zero suppressions). A second, `Kyberforge.VagueQualifier`, was cherry-picked and then deleted: 2 hits across the skill/agent corpus as it stood at the time of that measurement (2026-08-08, before the `.apm/` restructure), one marginal and one an unfixable false positive (`caveman/SKILL.md` quotes `of course` as an example of filler — a mention, not a use) that forced the repo's only Vale suppression comments. A third, `Kyberforge.CompositionNote`, landed with ADR-0020 and bans architecture and composition prose from a description; it is `level: error` like the rest, and it currently fires 10 times across `gitea-issues`, `gitea-labels-milestones`, `gitea-prs` and `gitea-workflow`, so `pre-commit run --all-files` is red on prose as well as on size until issue #99 lands. Also new is a sibling pre-commit hook, `skill-size-check` (`scripts/skill-size-check.sh`), which carries **two independent gate families that must not be conflated** (see "Skill context contract"). The agentskills.io spec backstop is `MAX_LINES=500` and `MAX_WORDS=2770`, both inclusive and both counting the **whole file including frontmatter** (2,770 is a word-count proxy for the 5,000-token limit, calibrated to the densest prose measured in this repo — 1.81 tokens per word — so even a worst-case `SKILL.md` at the ceiling stays under 5,000 tokens; it is not a percentile of the corpus). ADR-0020 adds a context budget measured differently: description characters 250 SUGGESTION / 400 FAIL, **body-only** words 600 SUGGESTION / 900 FAIL, plus deterministic checks that every boundary routing target resolves, that a body's named `references/.md` all exist, and — SUGGESTION-tier — that a boundary clause is present at all, that `## Gotchas` holds at most five entries, and that it stays under 25% of the body. `skill-audit/scripts/validate.sh` and `agent-audit/scripts/validate.sh` hold their own copies of the shared constants and `tests/test-skill-size-check.sh` asserts the copies agree, so a `SKILL.md` can no longer pass its own audit yet be blocked by the commit hook. Agents take the description gates and no body word gate. `python3` **and PyYAML** are hard requirements — the earlier hand-rolled frontmatter fallback is gone, because a fallback that silently mis-parses a scalar shape reports a vacuous pass. Scoped to `^plugins/[^/]+/\.apm/skills/[^/]+/SKILL\.md$` only, same as `vale-audit-prefilter-skill`, so it never lints `docs/research/examples/` reference skills. It's also exposed in the root-level `.pre-commit-hooks.yaml` as `kyberforge-skill-size-check` — it has no external asset dependency, so it needed no relocation, only exposure to external consumers. File scope (`SKILL.md` + agent files) and enforcement model (rules land directly in `styles/Kyberforge`, blocking immediately, no trial tier) stay unchanged; governance.md/CONTROLS.md were evaluated and excluded as rule sources (nothing prose-pattern-matchable to mine). House convention: banned phrasing that must be mentioned rather than used goes in backticks or a fenced code block — Vale skips code spans and fences, so no suppression is needed; inline `` (HTML-comment form; the MDX `{/* */}` form does not work in plain Markdown) is the fallback only where backticking is impossible. ### LESSONS.md Long-loop feedback log for patterns observed across sessions. Three or more entries on the same pattern graduate to the relevant standing file (e.g. a coding convention, a governance rule). Updated by the session-handoff skill or directly by the human. Lives at the repo root. diff --git a/LESSONS.md b/LESSONS.md index 074bb83..1b9a1ed 100644 --- a/LESSONS.md +++ b/LESSONS.md @@ -177,3 +177,87 @@ Across one review round, four fixes specified by the orchestrating reviewer were Mutation testing a review round's own fixes found repeatedly that a passing test was pinning nothing. Deleting `sync_dir`'s stale-directory wipe, its check-mode stale branch, or three of five `MIRROR_DIRS` entries each left the suite at 18/18 green; so did replacing the hooks trailing-newline normalisation with plain `cp`. A pair of concurrency assertions written to guard a reentrancy defect caught it 0 times in 10 runs against the deliberately broken script — and one of them was structurally incapable of ever catching it, because the broken code wrote to the system temp dir while the assertion inspected `$TMPDIR`. A fixture-leak fix ran green with and without the fix, verified only by external observation. Two manifest fixtures passed with the canonicalisation they claimed to cover deleted, rescued by an unrelated name-matching axis. In each case the test named the right behaviour in its description and asserted something adjacent to it. The cheap discipline that finds all of these: for every assertion, construct the revert it is supposed to catch and confirm it fails — and when an assertion survives every revert you can think of, that is not reassurance, it is the finding (one test only revealed itself as decoration once a sixth, differently-targeted revert was built for it). Fix: treat "which revert does this fail against?" as a required answer at the time an assertion is written, and record it where the assertion lives, since a test's own description is exactly the artifact that made the gap invisible. Graduation candidate: this overlaps 2026-08-09's "an assertion written to cover an accepted residual tends to assert the residual's presence rather than the behaviour it costs" and the same date's "assert on the expected members, so a derivation whose input vanished fails loudly instead of quietly covering less." Three entries circling one pattern — human review for promotion to `core/instructions/testing.md`. + +## 2026-08-14 — Vale's `existence` extension concatenates `raw:` entries, it does not alternate them + +A new `Kyberforge.CompositionNote` rule was first written with seven `raw:` entries, one per banned +phrasing. Vale loaded it without a diagnostic and it matched **zero of 43 files** — an outcome +indistinguishable from a clean corpus, and the exact shape of 2026-08-08's "a clean linter result can +mean nothing was checked". The cause is that `existence` joins multiple `raw:` entries into one +pattern rather than OR-ing them, so the rule was searching for all seven phrases concatenated. Every +pre-existing rule in this style has exactly one `raw:` entry, so nothing in the repo demonstrated the +difference, and the multi-entry form looks natural beside them. `tokens:` is the alternated form, +which is why `VagueWording` uses it. Fix: a new Vale rule is not landed until it has been shown to +*fire* — the standing revert-check applies to linter rules as much as to tests, and the revert here +is the broken multi-`raw:` form, which `tests/test-vale-hooks-consumer.sh` now fails against. + +## 2026-08-14 — Un-anchoring a description rule to reach mid-sentence text is unshippable + +Widening `DescriptionOpener` to catch `gitea-workflow`'s mid-description "This is the human-facing +entry point…" looked like a one-character change. Both that skill and `gitea-labels-milestones` +*open* with "Use when…" and satisfy the opener rule; the offending clause sits at character 377 and +300 of the folded value respectively, so the rule was never violated and never silently passed — it +simply had no jurisdiction, which is a different defect and takes a different fix. +Under `scope: text.frontmatter.description`, `^` +anchors to the start of the whole description value — and `vale-wrap.sh` has already flattened that +value to one physical line, so `(?m)` changes nothing. Un-anchoring is therefore the only route to +mid-description text, and measured across the corpus it scores 5 hits and 5 false positives: skills +legitimately quote user phrasings (`says "audit this skill"`) and write boundary clauses (`do not use +this skill to manage label definitions`). That is the `Kyberforge.VagueQualifier` deletion repeating. +Fix: keep the opener rule opener-anchored and give mid-description prose its own rule with its own +token list. A rule's scope anchor is part of its contract, not an implementation detail to relax when +a new case does not fit. + +## 2026-08-14 — A formatter in the commit path manufactures drift on a file with a clean git diff + +`apm audit --ci` failed on `.claude/settings.json` while `git diff` on that file was empty — the worst +possible pairing of signals, because the file matched HEAD exactly and every instinct says "nothing +changed here". The content was identical to apm's output to the byte; only the JSON key order +differed. `pretty-format-json --autofix` sorts object keys unless `--no-sort-keys` is passed, and its +`exclude:` listed fifteen generated manifests but not this file, so from the commit that first wrote +a hook entry there onward, apm's insertion-ordered output was silently re-sorted on the way in. apm +then replayed the install, produced its own order, and reported drift against a file no human had +touched. + +The provenance matters as much as the mechanism, and the first account of this entry got it wrong in +both directions. `git log --format='%h %ad %s' --date=iso` puts the introducing commit `2e395a4` at +2026-08-14 18:47 and the fix `7607522` at 21:54 — roughly three hours, not "weeks". And `2e395a4` is +the **first commit of the `refactor/trim-skills-agents-context` branch**, eleven minutes after the +base merge `f9b919d`; `git branch -a --contains 2e395a4` returns only that branch and its own +`remotes/origin/` tracking copy — two lines naming one branch, and `main` is not among them. So +this was not a latent defect inherited from `main`, it was manufactured inside the same PR that +diagnosed it, and the fixing commit's own message calling it "pre-existing … red at HEAD before +ADR-0020 work began" is the mis-attribution rather than the record. Two cheap commands would have +settled it before either sentence was written. + +Three general points. First, a tool-owned generated file that passes through an autofixing formatter +is drifted by construction, and the diff that would reveal it never appears in `git diff` — it only +exists between the formatter's input and its output, which nothing stores. Second, the fix is +self-undoing unless the exclude lands in the same commit: correcting the file alone means the hook +re-breaks it as it is staged. Third — the one this entry had to learn twice — "pre-existing" is a +claim about history, and history is queryable; a defect found while working on a branch feels +inherited, and the feeling is not evidence. A three-hour-old self-inflicted bug and a months-old +inherited one call for different responses, and writing the wrong one down converts a process failure +into a story about someone else's neglect. Fix: when a tool declares ownership of a path, add that +path to every autofixing hook's `exclude` at the moment ownership is declared, not when the drift is +noticed — and before describing any defect as pre-existing, run `git log -S` or +`git branch --contains` on the commit that introduced it. This repo gates marketplace-mirror, +plugin-content and vale-style drift deterministically and has no equivalent gate asserting tool-owned +paths stay out of formatter scope — `.claude/settings.json` was the sixteenth exclude and nothing +prevents a seventeenth. + +## 2026-08-16 — A rule reversed inside a retrofit leaves no trace unless someone writes it down + +`skill-author/SKILL.md:204` on `main` said "Keep reference chains one level deep — a reference file +that references another reference file is rarely loaded correctly." The ADR-0020 retrofit replaced it +with "Two hops from `SKILL.md`, never three" in `references/create.md` and `references/retrofit.md`, +which permits exactly the chain the old rule banned. The looser rule is the right one and the +retrofit could not have shipped without it: dispatch pushes each flow into its own file, so the +shipped structure is `SKILL.md` → `improve.md` → `retrofit.md`, and a one-level ceiling would have +made the mandatory dispatch pattern illegal. But ADR-0020 says nothing about chain depth, so the +reversal was carried entirely by the diff — the new text asserts the new rule with no sign that a +contradicting rule ever existed, and a reader who remembers the old one has no way to tell whether it +was overturned or overlooked. Fix: when a change inverts a standing authoring rule rather than +tightening or restating it, record the inversion where the rule's rationale lives — the ADR if the +ADR is the reason, here otherwise. A rule that quietly flips is indistinguishable from a rule that +was forgotten, and the second reading is the one that gets it re-added later. diff --git a/apm.lock.yaml b/apm.lock.yaml index 951e332..899b302 100644 --- a/apm.lock.yaml +++ b/apm.lock.yaml @@ -1,12 +1,12 @@ lockfile_version: '1' -generated_at: '2026-08-14T16:50:21.373462+00:00' +generated_at: '2026-08-14T21:26:25.156410+00:00' apm_version: 0.28.0 dependencies: - repo_url: Defame1297/holocron name: bin host: git.dev.rkdr.net - resolved_commit: c3ec5f2d3d00d625f632de3c40d1919e16727988 - version: 1.1.2 + resolved_commit: f9b919d7e3bd5e6b51fbdf88b32ace0438b313e0 + version: 1.1.3 virtual_path: plugins/bin is_virtual: true package_type: marketplace_plugin @@ -81,12 +81,13 @@ dependencies: .claude/skills/triage/SKILL.md: sha256:b819f0285e4e5814ac6472d0217f07dc9f23d2fdae3ebb0dd31d98360d8ac029 .claude/skills/write-docs/SKILL.md: sha256:0d06d0f6836a67532497ea61bd5a1294a8d80f529bf1ffb5c4b1fdc72e9cb51a .claude/skills/zoom-out/SKILL.md: sha256:2a6894c7f9b1c9c55f451c625a834c4f377e217b623a85280e55db5fe9cacf48 - content_hash: sha256:68316a438855468cd6d1286b3b458e68db4815149bd267c285c11027e98bc8f4 + content_hash: sha256:7acfaa806ae8379fe6351441ce8f973ac3ff8a02deb4a5e145968203d1176ef8 declared_license: MIT + exec_status: gated_pending_approval - repo_url: Defame1297/holocron name: core host: git.dev.rkdr.net - resolved_commit: c3ec5f2d3d00d625f632de3c40d1919e16727988 + resolved_commit: f9b919d7e3bd5e6b51fbdf88b32ace0438b313e0 version: 1.1.1 virtual_path: plugins/core is_virtual: true @@ -133,7 +134,7 @@ dependencies: - repo_url: Defame1297/holocron name: git host: git.dev.rkdr.net - resolved_commit: c3ec5f2d3d00d625f632de3c40d1919e16727988 + resolved_commit: f9b919d7e3bd5e6b51fbdf88b32ace0438b313e0 version: 1.3.3 virtual_path: plugins/git is_virtual: true @@ -240,7 +241,7 @@ dependencies: - repo_url: Defame1297/holocron name: gitea host: git.dev.rkdr.net - resolved_commit: c3ec5f2d3d00d625f632de3c40d1919e16727988 + resolved_commit: f9b919d7e3bd5e6b51fbdf88b32ace0438b313e0 version: 1.3.4 virtual_path: plugins/gitea is_virtual: true @@ -331,13 +332,14 @@ dependencies: - repo_url: Defame1297/holocron name: kyberforge host: git.dev.rkdr.net - resolved_commit: c3ec5f2d3d00d625f632de3c40d1919e16727988 - version: 1.4.1 + resolved_commit: f9b919d7e3bd5e6b51fbdf88b32ace0438b313e0 + version: 1.5.0 virtual_path: plugins/kyberforge is_virtual: true package_type: marketplace_plugin deployed_files: - .claude/agents/apm-orchestrate.md + - .claude/hooks/kyberforge/.apm/hooks/check-apm-current.sh - .claude/skills/agent-audit - .claude/skills/agent-audit/README.md - .claude/skills/agent-audit/SKILL.md @@ -415,6 +417,7 @@ dependencies: - .claude/skills/skill-author/scripts/new-skill.sh deployed_file_hashes: .claude/agents/apm-orchestrate.md: sha256:fbb78f7c8c58b018639e7a39f2f1b3ce2adcd6bed277b3c8f8cd70893698ec73 + .claude/hooks/kyberforge/.apm/hooks/check-apm-current.sh: sha256:96f44d63b5f4906ac1add5c32b718176ec10c8514615151d7303d9845c607b46 .claude/skills/agent-audit/README.md: sha256:a3a63acb80981975330bd9a86d81c8f3e632f7f2d97ac99951710d035fb52953 .claude/skills/agent-audit/SKILL.md: sha256:080f391ea25de8afc5a20478cbd382a8d28fcf70a7be7f012033dacb6e9bfed3 .claude/skills/agent-audit/assets/vale/.vale.ini: sha256:d643677585c603edcc8816b15d9c85c81b677ded02247b521ba8b88ae5bfdf57 @@ -483,12 +486,13 @@ dependencies: .claude/skills/skill-author/references/scripts.md: sha256:fe71da1fb3d947846ad1a37348f90866b0c4f7dbd81331cfe3300981656f6cc2 .claude/skills/skill-author/references/sources.md: sha256:27637acad4c0cdc7f1db15bc9cab339ebdac643e084642c58945ac2f81020c75 .claude/skills/skill-author/scripts/new-skill.sh: sha256:46c6903404b9eabbd6c83892213fdabf9cad193be64613b8c1b84c8dc37e8eb2 - content_hash: sha256:686b272f205a67d2e817e8458bd7ab442e64c29bab7b9123541a39873d3c8e55 + content_hash: sha256:c89a45409fd3e06a6c63ca8a8f0f0d9c8e10bbded73fdef15dd482eebc6b6a78 declared_license: MIT + exec_status: deployed - repo_url: Defame1297/holocron name: lint host: git.dev.rkdr.net - resolved_commit: c3ec5f2d3d00d625f632de3c40d1919e16727988 + resolved_commit: f9b919d7e3bd5e6b51fbdf88b32ace0438b313e0 version: 1.1.6 virtual_path: plugins/lint is_virtual: true @@ -517,6 +521,7 @@ dependencies: .claude/skills/vale-run/references/troubleshooting.md: sha256:b4c2bc67b413b102d9fe1cd7e5a248b19aba3dc3b60b4429e777977cf8021cd5 content_hash: sha256:7bc57b8852680bdcba32a6a6108b99ef6bcdf26eacfecd806c421ec5d1fd47d9 declared_license: MIT + exec_status: gated_pending_approval deployments: - kind: project-relative target: claude @@ -554,6 +559,15 @@ deployments: - git.dev.rkdr.net/Defame1297/holocron/plugins/lint active_owner: git.dev.rkdr.net/Defame1297/holocron/plugins/lint content_hash: sha256:efeee4fb01c27b9177239f059851cf1b736ca50a12793b94d5d65d30ab98bcd4 +- kind: project-relative + target: claude + value: .claude/hooks/kyberforge/.apm/hooks/check-apm-current.sh + runtime: null + scope: project + owners: + - git.dev.rkdr.net/Defame1297/holocron/plugins/kyberforge + active_owner: git.dev.rkdr.net/Defame1297/holocron/plugins/kyberforge + content_hash: sha256:96f44d63b5f4906ac1add5c32b718176ec10c8514615151d7303d9845c607b46 - kind: project-relative target: claude value: .claude/skills/agent-audit @@ -2687,6 +2701,15 @@ deployments: - git.dev.rkdr.net/Defame1297/holocron/plugins/bin active_owner: git.dev.rkdr.net/Defame1297/holocron/plugins/bin content_hash: sha256:2a6894c7f9b1c9c55f451c625a834c4f377e217b623a85280e55db5fe9cacf48 +- kind: uri + target: mcp + value: obsidian + runtime: claude + scope: project + owners: + - . + active_owner: . + content_hash: null mcp_servers: - obsidian mcp_configs: @@ -2694,9 +2717,12 @@ mcp_configs: name: obsidian transport: stdio args: - - '@bitbonsai/mcpvault@latest' + - '@bitbonsai/mcpvault@0.15.0' - docs/ registry: false command: npx +mcp_target_servers: + claude: + - obsidian mcp_config_provenance: obsidian: bin diff --git a/apm.yml b/apm.yml index bfaa15a..f727d09 100644 --- a/apm.yml +++ b/apm.yml @@ -1,5 +1,5 @@ name: holocron -version: 0.4.1 +version: 0.4.2 description: AI development skills for Claude Code and GitHub Copilot CLI — factory, design, implement, review, and cross-cutting workflows. license: MIT @@ -42,7 +42,7 @@ dependencies: # after a kyberforge release, check this first. executables: allow: - kyberforge#1.5.0: + kyberforge#1.6.0: hooks: true bin: true @@ -52,7 +52,7 @@ 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.4.1 + version: 0.4.2 owner: name: Defame1297 email: defame1297@rkdr.net @@ -79,7 +79,7 @@ marketplace: - name: kyberforge description: Skills and agents for creating, maintaining, and managing a Claude Code / Copilot CLI plugin marketplace. source: ./plugins/kyberforge - version: 1.5.0 + version: 1.6.0 category: Developer Tools - name: bin diff --git a/docs/adr/0020-skill-description-and-body-context-contract.md b/docs/adr/0020-skill-description-and-body-context-contract.md new file mode 100644 index 0000000..19853b4 --- /dev/null +++ b/docs/adr/0020-skill-description-and-body-context-contract.md @@ -0,0 +1,419 @@ +# Skills and agents are authored against a context budget, not a spec ceiling + +Every installed skill's `name` and `description` sits in every agent's context from the first token +of every session, whether or not the skill is ever invoked. Across this repo's 39 skills that is +23,427 characters — roughly 5,900 tokens — and the authoring rules that produced it optimised for +triggering reliability with no counter-pressure on size. This ADR sets the budget, the shape, and the +gates that hold them. + +**Status: accepted (2026-08-14).** + +## Context + +Every `file:line` citation in this ADR is against the base commit the decision was taken on, +`f9b919d7e3bd5e6b51fbdf88b32ace0438b313e0`, not against current `HEAD`. The change that carries this +ADR rewrites several of the cited files, so a citation resolved against the worktree will land on +unrelated text. Use `git show f9b919d:` to follow one. + +Measured before any change, at that commit. Method, so the figures are reproducible: sum +`len(name) + len(description)` over the frontmatter of every `plugins/*/.apm/skills/*/SKILL.md`, +folding `>` block scalars to the value the host actually loads (most descriptions here are folded +scalars, so counting raw lines measures indentation instead); tokens at the standard +~4-characters-per-token approximation `scripts/skill-size-check.sh` uses. Word counts are +whitespace-separated tokens, and are stated as **body-only** or **whole-file** every time, never bare. + +| | | +|---|---| +| 39 skill `name` + `description` | 23,427 chars, ~5,900 tokens, **preloaded every session** | +| 4 agent `name` + `description` | 1,325 chars, ~330 tokens, preloaded every session | +| skill bodies (body-only words) | median 684, mean 815, p90 1,349 | +| skill files (whole-file words) | median 816, mean 927, p90 1,526 | +| `MAX_WORDS` gate (`skill-audit/scripts/validate.sh:147`) | **2,770** whole-file — a density proxy, not a percentile | + +That last row is worth stating plainly, because it is the first thing this ADR is about. 2,770 is not +derived from the corpus distribution at all: per the derivation comment in +`scripts/skill-size-check.sh`, it is 2,770 words at the densest observed 7.22 chars/word ≈ 20,000 +chars ≈ the agentskills.io ~5,000-token ceiling. Neither percentile reaches it — 2× the body-only p90 +is 2,698 and 2× the whole-file p90 is 3,052 — and reading it as "2× p90" would pair a whole-file gate +against a body-only distribution, which is exactly the conflation this ADR exists to stop. + +Three findings drove this, none of which is "the descriptions drifted". + +**The rules mandate the bloat.** `skill-author/SKILL.md:104` requires indirect triggers ("even if the +user doesn't mention X explicitly") and `skill-audit/references/description-quality.md:21` requires +authors to "err toward being pushy". Both are enforced. The one rule that would delete the waste — +`skill-author/SKILL.md:102`, "not the skill's internal mechanics" — is judgment-only and is absent +from the FAIL conditions at `description-quality.md:45-50`. The enforced rules inflate; the deflating +rule does not bite. The result is measurable: `gitea-files` spends 147 chars listing six verbs, then +301 chars re-quoting the same six as user phrasings, in the same order. `apm-workflow` does the same +with six capability clusters. Across the twelve longest descriptions, 30.7% is capability +enumeration and 11.6% is composition or implementation detail that cannot affect a routing decision. + +**Capability enumeration in a description is a correctness hazard, not only a token cost.** +`plugins/kyberforge/docs/research/examples/skill-write/writing-skills/SKILL.md:154-158` reports a +measured failure: "when +a description summarizes the skill's workflow, an agent may follow the description instead of reading +the full skill content. A description saying 'code review between tasks' caused an agent to do ONE +review, even though the skill's flowchart clearly showed TWO reviews." `git-commits` is exactly that +shape — 74% of its description is capability enumeration, including a rules table (`header max 100 +chars, lowercase subject, no trailing periods, 11 standard types`) an agent can act on without ever +loading the body. + +**The upstream sources cannot settle this.** The four skill-writing references under +`plugins/kyberforge/docs/research/examples/skill-write/` disagree on what a description contains — +when-only (`writing-skills/SKILL.md:99`), what-and-when (`skill-creator/SKILL.md:67`, +`writing-skills/anthropic-best-practices.md:187`), triggers-only +(`writing-great-skills/SKILL.md:28`), and what-plus-when-plus-negative +(`write-skill/SKILL-TEMPLATE.md:5-6`). Those four paths are relative to that directory. +`writing-skills` and the Anthropic document it bundles contradict each other inside one skill +directory. They also disagree on whether +500 lines is binding, on the inline-versus-bundle threshold, and on the TOC threshold (>100 lines vs +>300 lines). "Grounded in the research" is therefore not available as a tiebreaker; a house choice is +required and this is it. + +A fourth observation shaped the body half. The best progressive-disclosure ratio in the repo belongs +to `apm-workflow` — a 421-word body dispatching to 3,006 words of references — and the worst two +belong to the skills that define the house standard: `skill-author` (2,623-word body / 1,247 words of +references) and `agent-author` (2,582 / 1,664). Measured the other way, whole-file, those two are +2,760 and 2,758 words — ten and twelve words under the 2,770 gate their own plugin enforces. A +ceiling that nothing approaches is not a constraint; a ceiling that two files have grown into is a +target. The two numbers for one file are the point: 2,623 and 2,760 describe the same `skill-author`, +and only one of them is what either gate measures. + +## Decision + +### Descriptions + +A description carries three things and nothing else: a **trigger clause**, at most one **capability +clause**, and a **boundary clause**. Capability enumeration, output-format detail, composition notes +("composes X rather than duplicating Y"), and implementation detail move to the body or to +`README.md`. + +- **250 characters SUGGESTION, 400 FAIL.** The agentskills.io 1,024-character limit remains as an + unchanged spec backstop. The SUGGESTION tier is what moves the average; the FAIL tier only stops + outliers. +- **A missing, valueless or `null` `description:` is a hard FAIL** in all three validators. That + reads as a trivial precondition and is not: a `description:` line with no value followed by + `model: sonnet` let a line regex capture the *next* key, which looked non-empty, so the "missing or + empty" branch never fired and every gate below it then early-returned on the genuinely empty folded + value — exit 0, zero output, on a blocking pre-push gate. Presence is decided on the YAML-folded + value and nowhere else. The field this contract is entirely about is the one field a gate must + never fail to notice is absent. +- **Boundary clauses compress** to `Not → .` and must name a target that + resolves to a real skill or agent. Resolution walks up **from the file being checked** to an + *authoring root* — the nearest ancestor holding `plugins/*/.apm/skills` or `plugins/*/.apm/agents`, + falling back to the nearest ancestor holding `.git`. Two passes rather than one interleaved walk, + so a nested `.git` (a submodule, a sub-package worktree) cannot beat a real monorepo root further + up. When an authoring root is found the universe is every skill and agent under + `/plugins/*/`, plus the target's own apm package and the packages that package declares in + its own `apm.yml` `dependencies.apm`. Sibling plugins resolve against each other, which is what a + monorepo means. Deployed `.claude/`/`.agents/` trees are consulted **only** when the walk found no + plugin monorepo root — whether it landed on a bare `.git` ancestor or on nothing at all. That is + the consumer case, where there is no monorepo to read. The condition is which of the two passes + matched, never a name-count delta: a single-plugin monorepo re-collects its own package and adds + no new name, so a delta test reads zero there and would pull the deployed trees back in. What the + resolver must never do is + derive the universe from its own location: a `${BASH_SOURCE}`-relative repo root leaked this repo's + 39-skill universe into every consumer repo running the hook through pre-commit, so a consumer skill + routing to `skill-audit` resolved against a plugin it had never installed. Checked + deterministically. A description carrying **no** boundary clause at all is a SUGGESTION, for skills + and agents alike: most descriptions want one, some genuinely have no near-miss sibling to exclude, + and that judgment is not a script's to make. +- **The verdict must not depend on whether `apm install` has been run.** Deployed trees are + gitignored install output, present only on a machine that has run it. Four cross-plugin targets + here (`gitea-branches` → `git-branches`, `gitea-branches` → `git-history`, `gitea-issues` → + `git-branches`, `gitea-workflow` → `git-workflow`) once resolved through `.claude/skills/` alone, + so the same commit measured 2 dangling targets on a developer machine and 6 on a fresh clone. A + gate shipping hot with no baseline cannot give two answers. Under the walk-up those four resolve + because sibling plugins are in the universe — no plugin here declares a cross-plugin apm + dependency, and none needs to. Verified: a tree holding only `plugins/` and the root `apm.yml`, + with no `.claude/` or `.agents/` anywhere, now produces findings identical to the working tree — + 26 description FAILs, 9 body FAILs, 2 dangling targets, 0 missing references, 58 SUGGESTIONs. +- **The universe is the apm marketplace, and nothing else.** A routing target resolves to a skill or + an agent, or it does not resolve. Host built-ins are deliberately outside it: `/compact`, `/clear` + and `/init` are Claude Code slash commands with no counterpart in Copilot CLI or Codex, so a + vendor-neutral `.apm/` description routing to one is a portability defect and the hard FAIL is a + true positive, not a false one. An allowlist of known built-ins was **rejected**: it answers a + different question ("does this exist on *some* host?"), it cannot answer that portably from a + single source file, and it goes stale the next time a host ships a command — reintroducing the + same-commit-two-verdicts failure the bullet above exists to close. An author who needs to mention + one writes it un-slashed (``the `compact` built-in``), which is not route notation and makes no + routing claim. +- **Blocking is scoped to a sentence, which makes sentence boundaries load-bearing.** A prose-form + target earns a hard error only when its own sentence names another target that *resolves*; route + notation (`/name`, `→ name`) is exempt and always blocks. So the splitter is part of the contract, + not a detail of it. `e.g. "…"` is not a sentence end, and a sentence opening with a code span or a + lowercase skill name is a start; getting either wrong moves targets between the two tiers in + opposite directions — a stranded corroborator silently demotes a real finding to SUGGESTION, and a + missed boundary lets one sentence vouch for a target it never stood beside, producing a hard FAIL + with no escape hatch. +- **The blanket pushiness rules are deleted.** `skill-author/SKILL.md:104` and + `description-quality.md:21` are replaced by a conditional: add an indirect trigger only where the + user's natural phrasing genuinely omits the domain word — true for the `gitea-*` family, false for + `git-commits`. Stating the same trigger twice in two registers is a FAIL. + +### Bodies + +The body carries the **decision procedure only**: ordered steps, decision branches, gates, and which +reference to load when. Lookup tables, spec restatements, output schemas, templates, and rationale +prose move to `references/` behind an explicit "read X when Y" trigger. + +- **600 words SUGGESTION, 900 FAIL, counted body-only** — everything after the closing `---` of the + frontmatter. The 2,770-word / 500-line spec backstop is unchanged, keeps its existing meaning + (conformance, not quality), and keeps counting the **whole file including frontmatter**. These are + two different gates measuring two different things, and conflating them is what produced the + current state. +- **Dispatch is mandatory at two or more mutually exclusive flows.** The body carries the dispatch + table and the gates that apply to every branch; each flow lives in its own self-contained + `references/` file. This is `apm-workflow/SKILL.md:33-41` promoted from accident to rule. "Two + mutually exclusive flows" is not decidable from file text, so this rule is auditor judgment — see + Enforcement below for what that means and does not mean. +- **Every `references/.md` a body names must exist.** A dispatch table pointing at a file that + was never written is a silently dead branch. Checked deterministically. +- **Gotchas are constrained.** A Gotcha must state a fact that contradicts a reasonable default — + something the agent gets wrong by acting sensibly. More than five entries is a SUGGESTION, as is a + Gotchas section exceeding 25% of the body; both are countable and both are checked + deterministically. A Gotcha that paraphrases a step in the body below it is a FAIL, but a FAIL an + auditor issues, not a script — semantic equivalence is not pattern-matchable. + +### Agents + +Agents take the same description gates — they are preloaded identically — and **no body word gate**. +A skill body is loaded into the caller's context, competing with the live conversation; an agent body +becomes the system prompt of a fresh context. The rationale for the 900-word FAIL does not transfer. + +That exemption is expressed in `agent-audit/scripts/validate.sh`, which has no body constant, and in +the `files:` pattern of the `skill-size-check` pre-commit hook, which is `SKILL.md`-only. It is *not* +expressed in `scripts/skill-size-check.sh` itself, which measures whatever path it is handed — +running it directly over `plugins/*/.apm/agents/*.agent.md` today reports 900-word body FAILs on +`git-orchestrate` (933), `gitea-orchestrate` (1,199) and `apm-orchestrate` (1,080). Agents escape by +file pattern, not by the script knowing the difference. Anyone widening that pattern to cover agents +would silently enforce a gate this ADR declines to set. + +A plugin-scope agent is a single file with no sibling `references/` directory, so it cannot disclose +to itself — it can only delegate to skills. `agent-audit` therefore gains a **delegation check**: an +agent body that restates a procedure owned by a skill it can invoke is a FAIL, with the fix being +"invoke `` instead". Length falls out of delegation rather than being gated directly. + +### Invocation as a design axis + +`skill-author` asks whether a skill is model-invoked or hand-invoked before writing a description. A +hand-invoked skill sets `disable-model-invocation: true` and carries one plain human-facing sentence +with no trigger list. + +Verified end-to-end rather than assumed: `plugins/bin/.apm/skills/zoom-out/SKILL.md:4` carries the +flag, apm passes it through verbatim to both `.claude/skills/zoom-out/SKILL.md:4` and the flat mirror +at `plugins/bin/skills/zoom-out/SKILL.md:4`, and `zoom-out` is the one installed skill absent from +the model-visible skill listing in a live session. It remains invocable as `/zoom-out`. + +### Merging siblings + +Two skills that share substantial content, name each other as near-misses, and differ only in the +type of input they take should be **one skill with a dispatch table**. This catches `skill-audit` + +`agent-audit` and is scoped to them; the author pair is explicitly excluded, because +`skill-author` and `agent-author` emit genuinely different artifacts (a skill directory versus a +one-or-two-file agent pair, per ADR-0005 and ADR-0016) and their overlap is in the improve flow +rather than the core job. + +**DEFERRED — not implemented in the change that carries this ADR. Tracked as issue #101.** Both +skills still exist separately, and this change made the split deeper rather than shallower: retrofit +to the dispatch pattern took `skill-audit` from 3 reference files to 7 and `agent-audit` from 4 to 8, +and their two same-named `references/description-quality.md` files now differ on 100 of ~120 lines +after normalising `skill`/`agent`, where before they were closer. The merge stays the decision; it +reopens ADR-0008 (agent-audit's single-file invocation contract) and touches every call site in +`skill-author`, `agent-author` and `forge`, which is why it is its own change and not a rider on +this one. Recorded here rather than dropped, so the gap between the rule and the tree is deliberate +and dated instead of discovered later. + +### Enforcement and rollout + +Gates land where the existing gates already live — no new layer. The table below is exhaustive about +which tier each rule is in, because the failure this ADR is most exposed to is a rule filed under +"Enforcement" that no validator implements: + +| Check | Applies to | Tier | Home | +|---|---|---|---| +| description characters (250 SUGGESTION / 400 FAIL) | skills, agents | deterministic | `scripts/skill-size-check.sh`; constants mirrored in `skill-audit/scripts/validate.sh` and `agent-audit/scripts/validate.sh` | +| body-only words (600 SUGGESTION / 900 FAIL) | skills | deterministic | `skill-size-check.sh`, `skill-audit/scripts/validate.sh` | +| description present and non-empty (ERROR) | skills, agents | deterministic | same | +| boundary target resolves to a real skill or agent (ERROR when written as `/name` or `-> name`, or when its own sentence names another target that resolves; SUGGESTION otherwise) | skills, agents | deterministic | same | +| boundary clause absent (SUGGESTION) | skills, agents | deterministic | same | +| Gotchas entry count over five (SUGGESTION) | skills | deterministic | same | +| Gotchas over 25% of the body (SUGGESTION) | skills | deterministic | same | +| every `references/.md` a body names exists (ERROR) | skills | deterministic | same | +| description opener, composition notes in a description | skills, agents | prose pattern | `plugins/kyberforge/.apm/skills/*/assets/vale/styles/Kyberforge/` | +| a Gotcha paraphrasing a body step | skills | **auditor judgment** | `references/body-discipline.md` | +| dispatch at two or more mutually exclusive flows | skills | **auditor judgment** | `references/body-discipline.md` | +| delegation: an agent body restating a skill's procedure | agents | **auditor judgment** | `agent-audit` | +| capability enumeration, restatement, trigger quality | skills, agents | **auditor judgment** | `references/description-quality.md` | + +The rows in bold are stated as FAILs in the Decision above and are FAILs an *auditor* issues. None of +them is countable: "does this Gotcha paraphrase step 4", "are these two flows mutually exclusive" and +"does this agent body restate what `git-commits` already owns" are semantic questions, and a script +that guessed at them would be a worse gate than no gate, because it would be believed. They are not +enforced, they are reviewed, and this table exists so that distinction is written down rather than +inferred from whether a validator happens to have been written yet. + +Two of the deterministic rows are tuned for **false positives over recall**, and what they decline to +see is part of the contract. On target extraction: a bare hyphenated name counts only inside a +boundary sentence, and a single-word name is never matchable bare — `research`, `triage`, `forge`, +`prototype` and `tdd` are all real skill names *and* ordinary English, so it must be written +`` `forge` `` or `/forge` to be seen at all. Grammar then decides whether a recognised target may +raise an error: one followed by an ordinary lowercase noun is a compound **modifier**, not a route +("use pre-commit hooks instead of ad-hoc scripts", "invoke the pull-request template"), so it is +confirm-only — it still resolves and still counts as a route when the name exists, but it can never +dangle. Only a *terminal* target can. The compressed arrow form `→ ` is exempt from that +follower test and is always error-eligible, because nothing reads as a compound modifier after an +arrow; a `/slash` target reached through a route verb is **not** exempt and takes the same test. The +simpler rule — "only marked targets may dangle" — was available and would have been wrong here: both +live true positives are bare, `research`'s "(use neuledge-context)" and the `gitea-labels-` / +`milestones` fold. On the body-shape checks: a `## Gotchas` heading must *end* in "gotchas", not +merely contain the word, so `## Gotcha handling` and `## Why gotchas matter` are prose sections and +are skipped; fenced code blocks are masked out of heading detection and entry counting, so a fenced +example list is not mistaken for the section; and a `references/` pointer named on a line +that also says the file is gone ("removed", "deprecated", "no longer") is read as a historical +mention rather than a dead dispatch entry. Note the 25% fraction is deliberately *not* fence-masked +on either side — fenced lines are real body words, and the fraction is measured against the whole +body. + +**The deterministic tier blocks immediately, with no baseline file.** + +Three pre-existing contradictions are fixed in the same change, because they are the contract: + +- `skill-audit/SKILL.md:58` asks whether the description opens with an action verb ("Audits…", + "Reviews…"), while `:56` defers the same question to `Kyberforge.DescriptionOpener` and + `skill-author/SKILL.md:101` requires an imperative "Use when…" opener. The criterion is + unsatisfiable against the house's own skills, both of which open with "Use when". +- `DescriptionOpener.yml` is anchored to `^This (skill|agent)\b`, which misses a plain `This …` + opener; it is widened here to `^This\b`. The anchor itself stays. Composition prose that sits + *mid*-description — `gitea-workflow`'s "This is the human-facing entry point…" at character 377, + `gitea-labels-milestones`'s "This is a cross-cutting shared skill…" at character 300 — was never in + the opener rule's scope and correctly is not: under `scope: text.frontmatter.description` the `^` + anchors to the start of the whole folded value, and un-anchoring to reach mid-description text was + measured at 5 hits and 5 false positives and rejected (`LESSONS.md`, 2026-08-14). The real gap is + that no rule covered that text at all, which a new token-list rule, `Kyberforge.CompositionNote`, + closes: 10 alerts across four `gitea-*` skills, 0 false positives. +- `description-quality.md:45-50` has no FAIL condition for internal-mechanics content, which is why + `skill-author/SKILL.md:102` never bit. + +## Consequences + +**Editing any non-compliant skill now requires retrofitting it first.** At decision time, 30 of 39 +descriptions exceeded 400 characters and 13 of 39 bodies exceeded 900 words — the latter counted +body-only, which is what the new gate measures; the pre-existing 2,770-word gate counts the whole +file including frontmatter, and the two must not be conflated. The change that carries this ADR also +retrofits kyberforge's own four author/audit skills, so the figures on landing are **26 and 9**. +With the gate hot and no baseline, a one-line +fix to `gitea-prs` cannot be committed until that skill meets the contract. This is deliberate — it +guarantees convergence and avoids a half-state — but it means the retrofit is lazy and *mandatory* +rather than deferred. Issue #99 tracks it and should be prioritised accordingly, and the risk it +carries is the ordinary one for hot gates: a gate expensive enough to be inconvenient gets bypassed +with `SKIP=` and loses its authority. + +**A second hot gate ships alongside it, and it is easy to miss.** `Kyberforge.CompositionNote` is +`level: error` like every other rule in that style, so `pre-commit run --all-files` is red on 10 +alerts across `gitea-issues`, `gitea-labels-milestones`, `gitea-prs` and `gitea-workflow` +independently of anything `skill-size-check` reports. Someone scoping the #99 retrofit off the size +findings alone will fix those and still be blocked. The two gates want fixing together. + +**A ceiling does not produce an average.** If every author writes to the 400-character FAIL, the +preload lands at 39 × 400 = 15,600 chars — a 33% cut off 23,427, not the ~50% intended. Writing to +the 250-character SUGGESTION instead lands at 9,750, a 58% cut. The halving depends entirely on the +250-character SUGGESTION tier being visible and respected. That tier works here in a way it does not +elsewhere in this repo: `skill-audit` already reports `PASS (N suggestions)` as a first-class +outcome. This is explicitly **not** the failure ADR-0013 records — Vale warnings are invisible +because vale's exit code keys on `error` alone, but these gates live in `validate.sh` and +`skill-audit`, where a SUGGESTION reaches the report. Realistic landing is somewhere in that 33-58% +band, not a guaranteed 50%. + +**A word gate cannot detect the defect it is standing in for.** `git-commits` carries twelve Gotchas +of which four restate steps in its own Workflow (`:32` ≡ step 9, `:33` ≡ step 9, `:36` ≡ step 2, +`:31` ≡ the description). Its body is 1,102 words and its whole file 1,217, so it does fail the +900-word body FAIL — but for its length, not for the restatement. The four duplicated Gotchas are 114 +words between them; delete every one and the file still fails, while a skill 250 words shorter with +the identical defect passes clean. The two properties are uncorrelated, which is why the counts are a +backstop to the dispatch rule and the Gotchas constraint — both of which are auditor judgment for the +semantic half, per the Enforcement table — and not a substitute for them. Reading the word gate as +the mechanism is the specific mistake this paragraph exists to prevent. + +**Some skills legitimately need more description budget than others.** A tiered limit keyed to +sibling density was considered and rejected as too clever; the flat 250/400 pair means the `gitea-*` +and `git-*` families — where every sibling shares a keyword and boundary clauses do real routing work +— are the ones most likely to sit at the FAIL tier permanently. If the retrofit shows that family +routing degrades, the tier is the first thing to revisit. + +**Four broken routing targets were found; two are fixed here and two are live.** Tracked as issue +#100. + +- `skill-audit` routed to `/skill-improve` twice in its description plus `README.md:10`, and no such + skill exists — the real target is `skill-author`. **Fixed here**, as a side effect of retrofitting + kyberforge's own skills. +- `agent-author` said "Do not use for read-only review — examine agent files manually", routing away + from `agent-audit`, the correct sibling. **Fixed here**, same way. Note this one was never + detectable by the resolvable-target check and never will be: "examine agent files manually" names + no target, and a check that resolves names cannot see a name that is absent. A misroute to nowhere + is a review finding, not a gate finding. +- `research` routes to `neuledge-context`, which exists only inside that string. **Live.** +- `gitea-issues` carries the literal string `gitea-labels- milestones` in its folded description, a + stray space introduced by YAML wrapping mid-token, breaking the skill name in preloaded text. + **Live** — the check reports it as a dangling `gitea-labels`. + +So the check fires on 3 of the 4 against the base commit and on 2 at the tip of this change, and +`tests/test-skill-size-check.sh` probes exactly those three by name rather than asserting a count, so +it degrades to SKIP as #100 lands rather than going stale. + +**Duplication between `skill-author` and `agent-author` survives un-gated.** The merge rule +deliberately excludes the author pair, so the commit-verification argument in four near-copies, the +root-cause grouping rule in four copies, and the wholesale clone of the "Improving an existing X" +flow all remain. Cache isolation makes them structurally unavoidable +(`skill-audit/SKILL.md:95` forbids cross-skill references; `LESSONS.md:107` records why), so the +options are a sync gate or continued drift. This is an input to issue #101, which carries both halves +of the kyberforge duplication problem — the deferred audit-pair merge and this — not a solved +problem. + +**Provenance frontmatter is explicitly out of scope.** `LESSONS.md:63` asserts that non-routing +frontmatter (`source_keys`, `category`, `version`) is loaded at agent startup, which would make the +7,352 characters of it across the corpus a third again on top of the description tax. Measured +against a live session on this Claude Code version, it is not: the model-visible skill listing +contains only `name` and `description`. That is host-observed rather than spec-guaranteed and says +nothing about Copilot CLI, but it is sufficient to establish that cutting `source_keys` would break +the ADR-0009 provenance machinery for no runtime gain. The metadata was added deliberately and stays. + +## Alternatives considered + +Upstream citations below are relative to +`plugins/kyberforge/docs/research/examples/skill-write/`, as in Context above. + +- **Keep pushiness, raise the budget to ~500 chars.** Undertriggering is the worse failure mode — a + skill that never fires is worth nothing regardless of cost — and `skill-creator/SKILL.md:67` + explicitly recommends being "pushy" against an observed undertriggering tendency. Rejected because + that claim is an unmeasured assertion about an older model, and because the correctness hazard in + `writing-skills/SKILL.md:154-158` cuts the other way: a fat description is not merely expensive, it is a + shortcut agents take instead of reading the body. Would have landed a 35% cut. +- **A trigger-eval loop to set lengths empirically.** `skill-creator/SKILL.md:337-404` specifies 20 + queries per skill, 8-10 positive and 8-10 near-miss, with a 60/40 train/test split selecting on + test score. This is the rigorous answer and the repo has deliberately never built it. Rejected + because it blocks the context cut behind a substantial new subsystem. +- **A repo-level aggregate preload budget** (≤12,000 chars across all skills, checked at pre-push). + The only option that measures the actual goal rather than a proxy. Rejected because it makes one + skill's edit fail on account of another skill's growth, and because it is meaningless for an + external consumer installing a subset of the plugins. +- **500-word body FAIL, matching `writing-skills/SKILL.md:217-221`.** Best-grounded in upstream and + would align this repo with the tightest source. Rejected because it fails 28 of 39 skills body-only + (35 of 39 measured whole-file), and a blunt gate gets satisfied by deleting content rather than + relocating it. +- **A shrinking baseline file** recording each non-compliant skill's current numbers, failing only on + growth. Would have made the retrofit a visible burn-down instead of a wall. Rejected in favour of + hot gates. +- **A sync gate over the duplicated spans** instead of a merge rule — generalising + `scripts/check-vale-style-sync.sh` to cover shared prose so duplication persists but drift cannot. + Rejected for the audit pair in favour of merging, which removes the duplication rather than + policing it, and removes a mutually-excluding near-miss pair from the router at the same time. It + remains the only available answer for the author pair. +- **Merging `skill-author` + `agent-author` as well**, taking kyberforge from seven skills to five. + Largest cut available. Rejected because it reopens ADR-0005, ADR-0008 and ADR-0016 together, and a + merged author skill would carry both the skill-directory scaffold and the dual-provider agent + scaffold behind one dispatch. +- **Demoting Gotchas** to the end of the body or into `references/gotchas.md`, removing its + position-based exemption from the dispatch rule. Maximum saving on the largest body construct + (6,830 words, 21% of all body text). Rejected because a gotcha read after the mistake is worthless. diff --git a/plugins/kyberforge/.apm/skills/agent-audit/README.md b/plugins/kyberforge/.apm/skills/agent-audit/README.md index c0ba507..d25374f 100644 --- a/plugins/kyberforge/.apm/skills/agent-audit/README.md +++ b/plugins/kyberforge/.apm/skills/agent-audit/README.md @@ -1,32 +1,40 @@ # agent-audit -Audits an agent definition for correctness and quality — a single vendor-neutral file at +Audits an agent definition for correctness and quality against the Claude Code and Copilot agent +references and the house context-budget contract (ADR-0020) — a single vendor-neutral file at plugin/APM scope, or a Claude Code and Copilot file pair at project/user scope. ## What it does -At **plugin/APM scope**, accepts the single `.apm/agents/.agent.md` file — there is no -counterpart. Structural checks via `validate.sh` hard-`FAIL` any frontmatter field outside the -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). +1. Runs `scripts/validate.sh` and `scripts/validate-provenance.sh` for structural and provenance + checks, plus `scripts/vale-wrap.sh` — a Vale prefilter that deterministically flags + non-imperative description openers, composition and architecture notes, vague wording, padding + phrases, "There is/are" sentence openers, and CC-specific "Use proactively" phrasing in a + Copilot or vendor-neutral description +2. Reads the agent file, and its counterpart when one exists, then loads the contract for its scope +3. Applies qualitative checks across description, body, delegation and comment discipline, loading + one rubric from `references/` per group +4. Outputs a compact findings report — findings only, grouped by dimension, each with Why and Fix — + and a result block with handoff to `agent-author` -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` -(required fields, kebab-case name, no placeholders, no CC-only fields in the Copilot file, no -Copilot-only fields in the CC file), provenance chain validation via `validate-provenance.sh` -(checks `source_keys` against `sources.md` at the plugin root — plugin/APM scope only), then -qualitative checks on description phrasing and system prompt quality. Step 1 also runs a -Vale-based prose sub-check via `vale-wrap.sh` against both files of the pair, using the -`Kyberforge` style (both files) and `KyberforgeCopilot` style (Copilot file only) — every alert -is a `FAIL`, cited by rule ID — falling back to Step 2 judgment when the `vale` binary is -unavailable or reports `0 files` scanned. Produces a compact findings report in the same format -as `skill-audit`. +Two things follow from ADR-0020 and are easy to get backwards. Agents take the **same** description +gates a skill takes — 250 characters SUGGESTION, 400 FAIL, since a `name` + `description` is +preloaded into every session either way — and **no body word gate at all**, because an agent body +becomes the system prompt of a fresh context rather than competing with the caller's live +conversation. Body length is judged through the delegation check instead: an agent body that +restates a procedure owned by a skill it can invoke is a FAIL, because a plugin-scope agent has no +sibling `references/` directory to disclose to and can only delegate. + +At **plugin/APM scope** the audit accepts the single `.apm/agents/.agent.md` file — there is +no counterpart, and pair consistency does not apply. `validate.sh` hard-`FAIL`s any frontmatter +field outside the vendor-neutral allowlist, since `apm compile` copies frontmatter verbatim to both +harnesses and an unsafe field cannot be silently dropped for just one of them. The allowlist lives +in the `apm-agent-allowlist` section of `references/field-inventory.md`, is read from there as data +by the script, and is deliberately not restated anywhere else in this skill (ADR-0009). + +At **project/user scope** the audit accepts either file in a CC `.md` / Copilot `.agent.md` pair, +derives the counterpart automatically, and validates both, including the field-leakage checks in +each direction. ## Usage @@ -42,18 +50,23 @@ Pass the path to either agent file as the argument. |------|---------| | `SKILL.md` | Skill instructions for agents | | `assets/vale/.vale.ini` | Vale config: scopes `Kyberforge` to `**/agents/*.md`, `Kyberforge`+`KyberforgeCopilot` to `**/*.agent.md` | -| `assets/vale/styles/Kyberforge/DescriptionOpener.yml` | Flags descriptions opening with "This skill/agent" instead of an imperative "Use when..." | +| `assets/vale/styles/Kyberforge/CompositionNote.yml` | Flags composition and architecture notes in a description ("cross-cutting", "entry point", "composes", "rather than duplicating") that belong in README.md | +| `assets/vale/styles/Kyberforge/DescriptionOpener.yml` | Flags descriptions opening with "This..." instead of an imperative "Use when..." | | `assets/vale/styles/Kyberforge/PaddingPhrase.yml` | Flags generic "see references/ for info" pointers instead of specific file references | | `assets/vale/styles/Kyberforge/SentenceOpenerThereIs.yml` | Flags sentences opening with "There is/are" instead of naming the subject directly | | `assets/vale/styles/Kyberforge/VagueWording.yml` | Flags vague capability wording ("helps with", "utilize", "assists with", "used for") in descriptions | | `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/description-quality.md` | Rubric for the description dimension — three-part shape, the 250/400-character budget, the hand-invoked contract, and the internal-mechanics FAIL | +| `references/body-and-delegation.md` | Rubric for the body, delegation and comment-discipline dimensions — the delegation FAIL and why agents take no body word gate | +| `references/scope-plugin-apm.md` | Scope contract for a single vendor-neutral APM agent file — allowlist, dimension routing, and the dimensions that do not apply | +| `references/scope-project-user.md` | Scope contract for a CC / Copilot pair — counterpart derivation, provider field rules, pair consistency | +| `references/validation-scripts.md` | Loaded only when a Step 1 script fails or cannot run — scope-detection walk-up, manual fallback checks, known script failures | | `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/validate.sh` | Structural validator — required fields, name format, placeholder detection, the ADR-0020 description budget, and the field rules for the detected scope | +| `scripts/validate-provenance.sh` | Provenance chain validation against `sources.md` at the package root (plugin/APM scope only) | | `scripts/vale-wrap.sh` | Drop-in `vale` wrapper that works around a frontmatter-description NLP scope limitation | | `tests/README.md` | (source-only) Bats test dependency and run instructions | | `tests/validate.bats` | (source-only) Bats tests for validate.sh | diff --git a/plugins/kyberforge/.apm/skills/agent-audit/SKILL.md b/plugins/kyberforge/.apm/skills/agent-audit/SKILL.md index 12f7c3b..6153619 100644 --- a/plugins/kyberforge/.apm/skills/agent-audit/SKILL.md +++ b/plugins/kyberforge/.apm/skills/agent-audit/SKILL.md @@ -1,18 +1,10 @@ --- name: agent-audit description: > - Use when the user wants to review an agent definition they wrote, says "audit this - agent", "check if my agent follows best practices", "review my agent file", or wants - to know if an agent pair is ready to ship — even if they don't use the word "audit". - Also invoke proactively after directly hand-editing an agent file pair outside - agent-author — an unaudited hand-edit is the same risk as unreviewed code. - Audits a Claude Code .md and Copilot .agent.md agent file pair across six dimensions: - structural validation, provider safety, description quality, body quality, comment - discipline, and pair consistency — plus provenance chain validation. Produces a - compact findings report - (findings only, no PASS noise) with Why and Fix per finding. Do not use to fix agent - files — use /agent-author instead. Do not use to audit SKILL.md files — use - /skill-audit instead. + Use when the user wants an agent definition audited — "audit this agent", + "review my agent file", "is this ready to ship" — or after hand-editing an + agent outside agent-author. Not applying fixes -> agent-author. Not a skill + directory -> skill-audit. allowed-tools: Bash Read metadata: category: factory @@ -26,80 +18,67 @@ metadata: ## Gotchas -- The unit of authoring at project/user scope is always a pair (CC `.md` + Copilot `.agent.md`). A missing counterpart is a FAIL under the kyberforge project convention at those scopes — neither the CC nor the Copilot platform itself requires a counterpart file. Label such findings as project convention violations, not platform spec failures. **At plugin/APM scope there is no pair** — the unit of authoring is a single vendor-neutral `.apm/agents/.agent.md` file, and Pair Consistency does not apply there at all (see below). -- Scope is detected by walking up from the agent file's directory: at each level, if `apm.yml` exists AND contains a top-level `type: instructions|skill|hybrid|prompts` line, that directory is an APM package root — plugin/APM scope. A `type:`-less `apm.yml` is marketplace-only (see `docs/research/docs/microsoft-apm/monorepo-and-repo-shapes.md`) — skip it and keep walking up. Otherwise, if `.git` is a directory at that level, stop there — project scope. If neither is found before the filesystem root, fall back to user scope at `$HOME`. `plugin.json`/`.claude-plugin/plugin.json` are no longer scope signals for this skill — a directory with only a `plugin.json` and no `apm.yml` falls through to project (or user) scope. -- `references/field-inventory.md` must exist for `validate.sh` to run. The script exits with an error if it is missing. -- Do not output findings while auditing — gather internally, surface in Step 3 report. +- Do not narrate PASS/FAIL per check while auditing. Gather findings internally and surface them only in the Step 4 report. Narrating each check as you go is the default failure mode here. +- Agents take the same 250/400-character description gates as skills and **no body word gate at all** — an agent body becomes the system prompt of a fresh context, so the 900-word skill ceiling does not transfer. Judge an over-long agent body through the delegation check, never by word count. +- At plugin/APM scope the agent is a single vendor-neutral file by design: never raise a pair-consistency finding there, and provider safety stops meaning Claude-Code-versus-Copilot field leakage. +- Vale reporting `0 files` scanned means NOT RUN, not clean. Fall back to full Step 3 judgment for every dimension it would have covered. -## Step 1 — Run structural validation +## Step 1 — Deterministic checks + +Resolve all three paths against this skill's own directory so they work from a repo checkout and an installed plugin cache alike. Run exactly: ```bash -bash scripts/validate.sh -bash scripts/validate-provenance.sh -scripts/vale-wrap.sh # project/user scope -scripts/vale-wrap.sh # plugin/APM scope — single file +bash scripts/validate.sh +bash scripts/validate-provenance.sh +scripts/vale-wrap.sh [] ``` -The script accepts either the CC file, the Copilot file, or (at plugin/APM scope) the single `.apm/agents/.agent.md` file. It detects provider from extension and scope from the walk-up above, then runs the checks for that scope. +`validate.sh` takes either half of a project/user-scope pair or the single plugin/APM-scope file, detects the provider from the extension and the scope by walking up, then checks required fields, kebab-case `name`, `FILL IN:` placeholders, template HTML comments left in frontmatter, the ADR-0020 description budget (250 chars SUGGESTION, 400 FAIL, measured on the folded YAML value) and the fields that scope permits. Its findings become the `### Structure` dimension — its FAILs and its SUGGESTIONs both — except the ones the Step 2 scope contract re-routes. -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`. +If a validation script fails or cannot run — Bash denied, `python3` or `vale` absent, `references/field-inventory.md` missing — read `references/validation-scripts.md`; what these scripts measure is not reproducible by reading. -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. +`validate-provenance.sh` prints nothing on success and runs at plugin/APM scope only, exiting 0 silently elsewhere. Its FAIL findings become a separate `### Provenance` dimension, and it emits Why and Fix itself — surface those verbatim. -`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. +`vale-wrap.sh` applies the bundled `Kyberforge` style as a prefilter. Pass no `--config`; the wrapper locates its own. At project/user scope pass both files of the pair, not only the one you were handed. Every rule is graded `error`, so every alert is a FAIL. Report each one citing its rule ID, filed under the dimension it belongs to, and do not re-derive it by judgment: -`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. +| Rule | Dimension | +|---|---| +| `Kyberforge.DescriptionOpener`, `Kyberforge.CompositionNote`, `Kyberforge.VagueWording`, `KyberforgeCopilot.ProactivePhrase` | description | +| `Kyberforge.SentenceOpenerThereIs`, `Kyberforge.PaddingPhrase` | body | -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 — Read the agent and load its scope contract -## Step 2 — Qualitative checks +Read the agent file end to end, and at project/user scope its counterpart too. A path containing `.apm/agents/` is plugin/APM scope; anything else is project or user scope. Each contract names the dimensions that apply there and where `validate.sh` findings other than Structure belong: -Read both agent files. Work through each dimension internally. Collect findings only; report in Step 3. +| Scope | Read | +|---|---| +| plugin/APM | `references/scope-plugin-apm.md` | +| project, user | `references/scope-project-user.md` | -**Description (both files):** -- Action-verb opening: description starts with a verb ("Reviews...", "Analyzes...", "Generates...") — FAIL if absent. Vale's `Kyberforge.DescriptionOpener` alert flags the specific known-bad "This agent..." opener directly; verifying an arbitrary opening word is genuinely a strong verb still requires judgment. -- Specificity: is the trigger condition stated precisely? — SUGGESTION if vague. Vale's `Kyberforge.VagueWording` alert covers known filler ("helps with", "utilize", ...) directly; report those as FAILs without re-deriving by judgment. -- `Use proactively` in a Copilot description: Vale's `KyberforgeCopilot.ProactivePhrase` alert (Copilot file only) flags this directly — report it without re-deriving by judgment. +## Step 3 — Qualitative audit -If a description finding is borderline, read `references/description-quality.md`. +Load a dimension's rubric before judging that dimension. -**Body:** -- Direct role instruction: system prompt opens with `You are a [role]. When invoked, [action].` — SUGGESTION if absent -- One job per agent: system prompt describes a single bounded task — SUGGESTION if scope appears unbounded -- Generic, non-specific reference pointers to the `references/` directory: Vale's `Kyberforge.PaddingPhrase` alert flags this directly — report it without re-deriving by judgment -- Sentences that open with "There is"/"There are": Vale's `Kyberforge.SentenceOpenerThereIs` alert flags this directly — report it without re-deriving by judgment +| Dimension | Read | +|---|---| +| description | `references/description-quality.md` | +| body, delegation, comment-discipline | `references/body-and-delegation.md` | -**Body/Frontmatter comments:** -- Inspect each comment block in the YAML frontmatter. For each comment, apply: *"Would the agent get this wrong without this comment?"* Flag any that answer "no" as padding. -- Look for patterns like `# Optional. ` or extensive inline guidance (more than 1–2 lines per field) that should be condensed or removed before shipping. -- This mirrors skill-audit's body-discipline check but applies to template documentation in the frontmatter — template guidance belongs in development; agent-ready files should have minimal comments. +Cite file and line number for every finding. -**Pair consistency (cross-file) — project/user scope only:** -- Both files exist — FAIL if counterpart is missing (kyberforge project convention; not a platform requirement from either CC or Copilot — label as such) -- The following checks are covered automatically by `validate.sh`; apply them manually only when the script cannot run: both system prompt bodies non-empty — FAIL if either is empty -- **Does not apply at plugin/APM scope** — there is only one file, by design; do not raise a Pair Consistency finding there under any circumstance. +## Step 4 — Report -**Unexpressable Claude-only behavior — plugin/APM scope only:** -- Read the description and body. If either implies a need the vendor-neutral frontmatter can no longer express — tool restriction, `isolation`, `memory`, or another Claude-only behavior that a hand-authored CC file could have declared — flag it as a SUGGESTION, never a FAIL. This is a known upstream schema limitation (APM's agent primitive has no per-target compile integrator, so `tools:`/`isolation`/etc. can't be emitted safely to both CC and Copilot — ADR-0016), not an authoring mistake. The finding exists to give the author visibility into the gap, not to imply the schema can be made to do something it can't. - - Example: a body that says "only use Read and Grep, never Edit" but the frontmatter has no `tools` field to enforce it — SUGGESTION, not FAIL. - -## Step 3 — Report - -Open with a coverage line. At project/user scope: +Open with a coverage line naming every dimension checked. At project/user scope: ```text -Checked: structure · provider-safety · description · body · comment-discipline · pair-consistency · provenance +Checked: structure · provider-safety · description · body · delegation · comment-discipline · pair-consistency · provenance ``` -At plugin/APM scope, omit `pair-consistency` — it does not apply when there is no pair: +At plugin/APM scope, drop `pair-consistency` — there is no pair to check. -```text -Checked: structure · provider-safety · description · body · comment-discipline · provenance -``` +Then output only the dimensions that have findings, grouped under H3 headings, FAILs before SUGGESTIONs within each. Omit clean dimensions — their absence is what confirms they passed. -Then output only dimensions that have findings, grouped under H3 headings, FAILs before SUGGESTIONs within each dimension. Omit clean dimensions entirely. `### Provenance` findings are sourced verbatim from `validate-provenance.sh` output — copy them without rephrasing. - -For each finding: +Each finding: ```text FAIL/SUGGESTION — file:line @@ -107,18 +86,4 @@ FAIL/SUGGESTION — file:line Fix: ``` -Close with: - -```text -## Result - -PASS -PASS · P info -PASS (N suggestions) -PASS (N suggestions) · P info -FAIL (N fails · M suggestions) -FAIL (N fails · M suggestions) · P info -Run /agent-author to address findings. -``` - -Omit `Run /agent-author to address findings.` when there are no findings at all. Do not apply fixes — report and propose only. +Close with a `## Result` block holding one line: `PASS`, `PASS (N suggestions)`, or `FAIL (N fails · M suggestions)`, each optionally followed by ` · P info`. INFO findings are observational and never change PASS/FAIL; omit `· P info` when there are none. Add a second line, `Run agent-author to address findings.`, whenever there is at least one finding. Do not apply fixes — report and propose only. diff --git a/plugins/kyberforge/.apm/skills/agent-audit/assets/vale/styles/Kyberforge/CompositionNote.yml b/plugins/kyberforge/.apm/skills/agent-audit/assets/vale/styles/Kyberforge/CompositionNote.yml new file mode 100644 index 0000000..90ea015 --- /dev/null +++ b/plugins/kyberforge/.apm/skills/agent-audit/assets/vale/styles/Kyberforge/CompositionNote.yml @@ -0,0 +1,13 @@ +extends: existence +message: "Composition or architecture note in a description: '%s' — a description carries a trigger, one capability clause and a boundary clause only; move this to README.md" +level: error +scope: text.frontmatter.description +ignorecase: true +tokens: + - cross-cutting + - shared (skill|agent) + - human-facing + - entry[- ]point + - composes + - rather than duplicating + - replaces the (old|former|previous) diff --git a/plugins/kyberforge/.apm/skills/agent-audit/assets/vale/styles/Kyberforge/DescriptionOpener.yml b/plugins/kyberforge/.apm/skills/agent-audit/assets/vale/styles/Kyberforge/DescriptionOpener.yml index d978d73..1f41236 100644 --- a/plugins/kyberforge/.apm/skills/agent-audit/assets/vale/styles/Kyberforge/DescriptionOpener.yml +++ b/plugins/kyberforge/.apm/skills/agent-audit/assets/vale/styles/Kyberforge/DescriptionOpener.yml @@ -4,4 +4,4 @@ level: error scope: text.frontmatter.description ignorecase: true raw: - - '^This (skill|agent)\b' + - '^This\b' diff --git a/plugins/kyberforge/.apm/skills/agent-audit/references/README.md b/plugins/kyberforge/.apm/skills/agent-audit/references/README.md index acb1683..58bda01 100644 --- a/plugins/kyberforge/.apm/skills/agent-audit/references/README.md +++ b/plugins/kyberforge/.apm/skills/agent-audit/references/README.md @@ -10,6 +10,10 @@ Additional documentation agents load on demand. | File | Purpose | |------|---------| -| `description-quality.md` | Qualitative guide for borderline description findings — action-verb rules, specificity criteria, proactive-use caveat, length limits. | -| `field-inventory.md` | Canonical list of valid CC and Copilot agent definition fields. Load when the script needs authoritative field lists for structural validation. | +| `description-quality.md` | Rubric for the description dimension — three-part shape, the 250/400-character budget, the hand-invoked contract, and the internal-mechanics FAIL. | +| `body-and-delegation.md` | Rubric for the body, delegation and comment-discipline dimensions — the delegation FAIL, why agents take no body word gate, and what an agent body is for. | +| `scope-plugin-apm.md` | Contract for a single vendor-neutral `.apm/agents/.agent.md` file — allowlist, dimension routing, and the dimensions that do not apply. | +| `scope-project-user.md` | Contract for a Claude Code / Copilot file pair — counterpart derivation, provider field rules, and pair consistency. | +| `validation-scripts.md` | Loaded only when a Step 1 script fails or cannot run — scope-detection walk-up, manual fallback checks, and known script failures. | +| `field-inventory.md` | Authoritative field lists, read as data by `validate.sh`: valid CC and Copilot agent fields, and the vendor-neutral plugin/APM allowlist. | | `sources.md` | Research provenance records for skill content. Load only when tracing the origin of a specific rule or field constraint. | diff --git a/plugins/kyberforge/.apm/skills/agent-audit/references/body-and-delegation.md b/plugins/kyberforge/.apm/skills/agent-audit/references/body-and-delegation.md new file mode 100644 index 0000000..ef9e598 --- /dev/null +++ b/plugins/kyberforge/.apm/skills/agent-audit/references/body-and-delegation.md @@ -0,0 +1,121 @@ +--- +source_keys: + - context7-websites-code-claude + - claude-code-plugins-docs + - claude-code-subagents-docs + - context7-github-en-copilot + - github-custom-agents-configuration +--- + +# Body, Delegation and Comment Discipline Reference + +Upstream source: Claude Code subagent and plugin references, GitHub Copilot custom-agents +configuration. House contract: ADR-0020, the context budget. + +Read this when judging the **body**, **delegation** and **comment-discipline** dimensions. + +## The core test + +For every sentence in the body, ask: **"Would the agent get this wrong without this instruction?"** + +If no — cut it. The agent already knows it from general training. Adding it wastes tokens and +dilutes the signal of what matters. + +## Agents take no body word gate + +ADR-0020 gates a skill body at 600 words SUGGESTION / 900 FAIL and deliberately gates an agent body +at nothing. The two are not the same construct: a skill body is loaded into the caller's live +context and competes with the conversation already there, while an agent body *becomes* the system +prompt of a fresh context that has nothing else in it. The rationale for the 900-word ceiling does +not transfer, so: + +- **Never report an agent body as too long on a word count.** There is no number to cite. +- **Never add such a gate to `scripts/validate.sh`.** `tests/validate.bats` pins its absence with a + body far past 900 words that must still pass, and adding one would contradict the ADR. +- The one length signal that does apply is the Copilot runtime's 30,000-character body limit, which + `validate.sh` already reports as a SUGGESTION because content past it is silently truncated. + +Length is judged through the delegation check below instead, which is the defect a word count was +standing in for anyway. + +## The delegation check + +A plugin-scope agent is a single `.apm/agents/.agent.md` file with no sibling `references/` +directory. It cannot progressively disclose to itself — it can only delegate to skills. So a +procedure spelled out in an agent body that a skill the agent invokes already owns is not a +shortcut: it is a second copy of that procedure, and the second copy drifts. This is the +characteristic agent defect, the way a stale README row is the characteristic skill defect. + +**An agent body that restates a procedure owned by a skill it can invoke is a FAIL.** The Fix is +always the same shape: invoke `` instead. + +How to apply it: for each procedural block in the body — a rule list, a numbered sequence, a +constraint table — ask which skill owns that procedure. If the agent names that skill anywhere (its +dispatch table, its routing prose, its frontmatter), the block is a restatement and the skill is +already there to be invoked. + +Worked example. The three `*-orchestrate` agents exist to compose domain skills — `git-orchestrate` +(933 body words), `gitea-orchestrate` (1,199) and `apm-orchestrate` (1,080) — so any step they +spell out that the composed skill already owns is the defect. `git-orchestrate:24-31` carries a +"Hard rules" list (Conventional Commits types, atomic commits, never commit secrets, git trailers) +that `git-commits` owns and that `git-orchestrate:44` routes to by name; `:39` concedes the point +outright, noting the sub-skills "carry their own local copies of these rules". Two copies, one +authority, and nothing keeping them in step. + +What is **not** a finding under this rule, because no skill owns it: + +- The dispatch table itself — which operation routes to which skill. +- Safety gates the agent enforces before dispatching, and refusals it makes on its own authority. +- The input contract and the structured output the agent's caller consumes. +- Session state the agent carries across skill invocations. + +## What the body is for + +Include what the fresh context lacks: + +- A direct role instruction opening the prompt: `You are a [role]. When invoked, [action].` +- One bounded job, stated so the agent knows what it must refuse. +- The dispatch, gates, inputs and outputs listed above. +- **Error handling** — what the agent does on malformed, missing or contradictory input: stop and + report, or degrade to a named fallback. Absent it, the agent invents a recovery, and a + subagent's invented recovery is invisible to its caller until the output is wrong. +- Non-obvious environment facts and project-specific conventions it cannot infer. +- One default per decision point with one escape hatch. + +Do not include at all: + +- Concepts the agent already knows (what JSON is, how HTTP works, what a CSV is) +- Exhaustive option lists — pick a default; the agent does not benefit from choosing +- Steps the agent handles independently — over-specifying leads to unproductive paths +- Restatements of the description, which is already in context + +## Comment discipline + +Inspect every comment block in the YAML frontmatter and apply the core test to each: *would the +agent get this wrong without this comment?* Template scaffolding — `# Optional. `, more than a line or two of inline guidance per field — belongs to development, not +to a shipped file. At plugin/APM scope the stakes are higher than tidiness: `apm compile` copies +frontmatter verbatim to every target, `` is not valid YAML, and `validate.sh` FAILs a +frontmatter block that still contains one. + +## Auditing guidance + +Flag as FAIL if: + +- The body restates a procedure owned by a skill the agent can invoke — Fix: invoke `` + instead +- A sentence answers "no" to the core test — it is padding +- A decision point presents a menu of options with no default +- An instruction repeats content already in the description +- Frontmatter comments are template scaffolding rather than instruction, or are HTML comments at + plugin/APM scope +- A prescriptive sequence is used where flexibility is fine, or the reverse + +Flag as SUGGESTION if: + +- The body does not open with a direct role instruction +- The body specifies no error handling — nothing tells the agent what to do with malformed, + missing or contradictory input +- The job the agent describes is unbounded, or bounded only implicitly +- A rationale is missing from a rule the agent is expected to enforce — present but unexplained +- Comments are useful but verbose enough to bury the field they annotate diff --git a/plugins/kyberforge/.apm/skills/agent-audit/references/description-quality.md b/plugins/kyberforge/.apm/skills/agent-audit/references/description-quality.md index 17f387d..d3863af 100644 --- a/plugins/kyberforge/.apm/skills/agent-audit/references/description-quality.md +++ b/plugins/kyberforge/.apm/skills/agent-audit/references/description-quality.md @@ -1,7 +1,6 @@ --- source_keys: - context7-websites-code-claude - - claude-code-plugins-docs - claude-code-subagents-docs - context7-github-en-copilot - github-custom-agents-configuration @@ -9,41 +8,123 @@ source_keys: # Agent Description Quality Reference -Load this file when a description finding is borderline and you need to make a precise call. +Upstream source: Claude Code subagent reference, GitHub Copilot custom-agents configuration. +House contract: ADR-0020, the context budget. The house contract is narrower than either +platform's schema rather than a reinterpretation of it: where both speak, both must be satisfied. -## Action-verb opening +## Why the description is the expensive part -The description must open with an imperative or present-tense verb that describes what the agent does ("Reviews...", "Audits...", "Generates...", "Analyzes..."). Avoid: -- Noun phrases: "An agent that..." — no verb -- "This agent..." or "Use this when..." — passive framing -- "Helps with..." — too vague to be a clear verb +At startup an agent loads only the `name` and `description` of every installed skill and agent. +The body is never seen until the agent is invoked. The description therefore carries the entire +triggering burden **and** is paid for in every session, whether the agent fires or not. -**Borderline call:** "Validates and reviews..." is acceptable — two verbs is fine if both are specific. "Assists in reviewing..." is not — "assists" is vague filler. +A second cost is less obvious and is a correctness hazard rather than a token cost: a description +that summarises the workflow is a shortcut the caller takes *instead of* reading the body. A +measured failure upstream — a description saying "code review between tasks" — produced one review +where the body's flowchart specified two. -## Specificity of trigger condition +## Step 0 — establish which contract applies -The description must state what specifically triggers the agent. Generic phrasing fails: -- Too vague: "when the user needs help with agents" -- Acceptable: "when the user says 'audit this agent', 'check if my agent follows best practices', or wants to know if an agent pair is ready to ship" +Read the frontmatter before judging a single word. -Include indirect triggers: "even if they don't use the word 'audit'" or "even if the user doesn't phrase it as a review request". If the agent should activate on a recognisable user goal (not just literal keyword matches), name that goal. +- **`disable-model-invocation: true` or `user-invocable: false`** — the agent is hand-invoked. Its + description is never matched against user intent, so it is not a routing string. It carries **one + plain human-facing sentence** stating what the agent does. Audit it for that and nothing else. + Reporting a missing trigger clause, a missing boundary clause or absent indirect triggers on a + hand-invoked agent is a wrong finding, not a strict one. Both fields are Copilot-only and neither + is on the vendor-neutral APM allowlist, so this case arises in a Copilot `.agent.md` at + project/user scope and nowhere else. Its Claude Code counterpart has no equivalent field and stays + model-invoked, so the two halves of the pair carrying differently shaped descriptions is expected + there rather than a pair-consistency finding. +- **No such flag** — the agent is model-invoked and the rest of this file applies. -**Borderline call:** If the description covers direct triggers but omits common indirect phrasings that a user would plausibly use, mark as SUGGESTION (not FAIL) — the agent still activates, just less reliably. +## The three-part shape -## `Use proactively` +A model-invoked description carries exactly three things: -For CC files: including "Use proactively" signals the CC runtime to offer the agent unprompted when conditions are met. This is CC-specific — use it when the agent should activate without an explicit user request. +1. **Trigger clause.** When to invoke, phrased imperatively: `Use when ...`. Not `This agent ...` — + the caller is deciding whether to act, not reading a catalogue entry. +2. **At most one capability clause.** What it does, in one clause. Never an enumeration. +3. **Boundary clause.** Compressed form: `Not -> .` The target must resolve to + a real skill directory or agent file in the authoring source. -For Copilot files: this phrase has no effect. Use `user-invocable: false` / `disable-model-invocation: true` for equivalent Copilot behavior. Flag `Use proactively` in a Copilot description as a SUGGESTION (not FAIL) — it causes no harm, just has no effect. +Everything else belongs in the body or in the plugin's `README.md`. -## Length and hard limits +## Indirect triggers — conditional, never blanket -- CC agent descriptions: no documented character limit, but keep under 500 characters to avoid truncation in UI contexts. -- Copilot agent descriptions: no separate documented limit, but the overall 30,000-character body limit applies to the full file. -- Skill descriptions (SKILL.md): hard 1024-character limit enforced by the platform. +Add "even if the user doesn't say X" **only where the user's natural phrasing genuinely omits the +domain word.** True for the `gitea-*` family: people say "create an issue", not "create a Gitea +issue". False for `git-commits`: nobody asks for a commit without saying commit. A blanket +indirect-trigger clause on an agent whose domain word is unavoidable is padding charged to every +session. -## Do not use when +## Near-miss exclusions -Include a "Do not use when..." clause only if a near-miss agent or skill exists that could steal activations. Omitting it is not a finding. Including it is correct when there is a real confusion risk (e.g., `/agent-audit` vs `/skill-audit`). +Add a boundary clause only where a sibling skill or agent could plausibly steal the activation. Use +strong near-misses — queries that share keywords but need something different — not weak ones. One +boundary clause per genuine near-miss; a list of four is enumeration wearing a boundary's clothes. -**Borderline call:** If the "Do not use when" clause is present but the exclusion described is already obvious from context, mark as SUGGESTION to tighten or remove — not FAIL. +## Before / after + +```yaml +# FAIL — a noun-phrase opener rather than a trigger, capability enumeration in +# place of one capability clause, and no boundary clause at all, preloaded into +# every session forever. (The live git-orchestrate description, 254 chars.) +description: Orchestrates git workflow operations for other agents. Invoke when a + caller needs a multi-step or destructive git operation (rebase, force-push, branch + deletion) coordinated across domain skills with safety gates, session context, and + structured results. + +# PASS — trigger, one capability clause, boundary. The operation list and the +# safety-gate mechanics are the body's job; the router cannot act on them. +description: > + Use when an agent caller needs a multi-step or destructive git operation + dispatched and safety-gated. Not conversational git help -> git-workflow. +``` + +## Auditing guidance + +Flag as FAIL if: + +- **Over 400 characters.** Measured on the folded YAML value, not the raw source lines. + `validate.sh` reports the number; do not re-derive it, but do point the Fix at what to cut. Agent + descriptions have no platform-documented ceiling of their own — unlike a skill's 1,024-character + spec limit, the 400-character house ceiling is the only hard limit there is, so do not go looking + for a backstop behind it. +- **Internal mechanics appear in the description.** Any of: + - capability enumeration or a feature list; + - output-format detail ("Produces a compact findings report with Why and Fix per finding"); + - composition or architecture notes ("composes X rather than duplicating Y", "a cross-cutting + shared agent", "the human-facing entry point", "replaces the old flat invocation"); + - implementation detail ("self-validates via a bundled deterministic script"). + + None of it can change a routing decision and all of it is preloaded. + `Kyberforge.CompositionNote` catches the common phrasings deterministically; the rest is + judgment. This is the rule that deflates a description, so apply it before reaching for length. +- **The same trigger stated twice in two registers** — a verb list, then the same verbs re-quoted + as user phrasings, usually in the same order. One register, whichever routes better. +- **Descriptive rather than imperative phrasing** (`This agent ...`, `This is the ...`). + `Kyberforge.DescriptionOpener` catches any opener matching `^This`. There is no action-verb rule + here and never was a defensible one: an `Orchestrates ...` or `Audits ...` opener is a catalogue + entry, not a trigger. +- **Vague capabilities** ("helps with agents" where "audits an agent definition pair" was + available). `Kyberforge.VagueWording` catches the known filler; imprecision outside that list is + judgment. +- **A boundary clause naming a target that does not resolve** to a real skill directory or agent + file in the authoring source. `validate.sh` resolves this for agent files at both scopes and + reports each unresolved target itself — take its verdict rather than re-resolving the name by + hand, because a hand-walk over a different universe can contradict it. What is left to you is + semantic and the script cannot reach it: whether a target that *does* resolve is the right + sibling to exclude, and whether a clause naming no target at all ("examine the files manually") + should have named one. +- **`Use proactively` in a Copilot or vendor-neutral description.** + `KyberforgeCopilot.ProactivePhrase` catches it. The phrase steers the Claude Code runtime and + does nothing anywhere else, so in a `.agent.md` it is preloaded text that buys no behaviour. +- **Trigger-list, boundary or indirect-trigger content on a hand-invoked agent** — see Step 0. + +Flag as SUGGESTION if: + +- **Over 250 characters** but at or under 400. This tier is what moves the corpus average; the FAIL + tier only stops outliers. Report it rather than treating a 399-character description as clean. +- A near-miss exclusion is present but targets a weak near-miss. +- An indirect trigger is present and warranted but could name the omitted phrasing more precisely. diff --git a/plugins/kyberforge/.apm/skills/agent-audit/references/scope-plugin-apm.md b/plugins/kyberforge/.apm/skills/agent-audit/references/scope-plugin-apm.md new file mode 100644 index 0000000..54a65e1 --- /dev/null +++ b/plugins/kyberforge/.apm/skills/agent-audit/references/scope-plugin-apm.md @@ -0,0 +1,58 @@ +--- +source_keys: + - claude-code-plugins-docs + - claude-code-subagents-docs + - github-custom-agents-configuration +--- + +# Plugin/APM Scope Contract + +Read this when the agent file sits at `/.apm/agents/.agent.md` — a single +vendor-neutral file inside an APM package, with no counterpart anywhere. + +## What is different here + +`apm compile` copies an agent's frontmatter **verbatim** to every target harness. There is no +per-target integrator to reconcile a Claude-Code-only field with a Copilot-only one, so the file +cannot carry either (ADR-0016). That single fact drives everything below. + +## Frontmatter allowlist + +The permitted keys are the `apm-agent-allowlist` section of `references/field-inventory.md`. Read +them from there. Do not recite the list in a finding, do not work from memory, and do not trust any +restatement of it you find elsewhere in this repo: the list is data with one home (ADR-0009), it +has changed before, and `validate.sh` parses that same section at load time, so a recitation is a +copy that can disagree with the check the agent just ran. + +`field-inventory.md` records why a denylist-shaped field is admitted where an allowlist-shaped one +is not. Read that note before arguing with a finding about it. + +## Dimension routing + +`validate.sh` findings land as follows at this scope: + +| Finding | Dimension | +|---|---| +| any frontmatter key outside the allowlist; body over the 30,000-character Copilot limit | Provider safety | +| everything else — missing or malformed field, `name` not matching the filename stem, empty body, absent frontmatter, template HTML comments, description length | Structure | +| — | Pair consistency never applies | + +**Provider safety means something else here.** At project/user scope it asks whether a field leaked +across the Claude Code / Copilot boundary. At this scope there is no boundary and no pair: it asks +whether every field survives a verbatim copy to *every* target. Report it in those terms — a +finding phrased as "CC-only field in a Copilot file" is the wrong finding here. + +**Pair consistency never applies.** There is one file by design. `validate.sh` never emits a +missing-counterpart FAIL at this scope, and neither do you, under any circumstance. Drop +`pair-consistency` from the Step 4 coverage line rather than reporting it clean. + +## Behaviour the schema cannot express + +Read the description and body. If either implies a need the vendor-neutral frontmatter can no +longer express — a tool restriction, `isolation`, `memory`, or another Claude-only behaviour a +hand-authored CC file could have declared — flag it as a **SUGGESTION, never a FAIL**. This is a +known upstream schema limitation (ADR-0016), not an authoring mistake, and the finding exists to +give the author visibility into the gap rather than to imply the schema can be made to close it. + +Example: a body saying "only use Read and Grep, never Edit" with no `tools` field to enforce it. +A denylist-shaped restriction is the available half of that — see `field-inventory.md`. diff --git a/plugins/kyberforge/.apm/skills/agent-audit/references/scope-project-user.md b/plugins/kyberforge/.apm/skills/agent-audit/references/scope-project-user.md new file mode 100644 index 0000000..d91a5ad --- /dev/null +++ b/plugins/kyberforge/.apm/skills/agent-audit/references/scope-project-user.md @@ -0,0 +1,59 @@ +--- +source_keys: + - context7-websites-code-claude + - claude-code-subagents-docs + - context7-github-en-copilot + - github-custom-agents-configuration +--- + +# Project and User Scope Contract + +Read this when the agent file is not under `.apm/agents/` — a Claude Code `.md` and Copilot CLI +`.agent.md` **pair**, at project scope (`/.claude/agents/` and `/.github/agents/`) or +user scope (`~/.claude/agents/` and `~/.copilot/agents/`). `validate.sh` derives the counterpart +from whichever half it was handed; audit both. + +## The pair is a house convention + +Neither platform requires a counterpart file. The pair is a kyberforge convention (ADR-0005), so a +missing counterpart is a FAIL against **this repo's** convention and must be labelled that way in +the finding, not presented as a platform spec failure. + +## Dimension routing + +`validate.sh` findings land as follows at these scopes: + +| Finding | Dimension | +|---|---| +| a Claude-Code-only field in the Copilot file, a Copilot-only field in the CC file, a tool the runtime withholds from subagents, body over the 30,000-character Copilot limit | Provider safety | +| counterpart file not found | Pair consistency | +| everything else — missing or malformed field, name format, empty body, absent frontmatter, description length | Structure | + +The two field lists are the `claude-code-only-fields` and `copilot-only-fields` sections of +`references/field-inventory.md`. Read them from there rather than from memory; `validate.sh` parses +those same sections, so any restatement is a copy that can disagree with the check (ADR-0009). + +## Field and naming rules that differ by provider + +- `name` must match the filename stem in a **Copilot CLI** `.agent.md`. Claude Code imposes no such + rule, so a CC file whose `name` differs from its filename is not a finding. +- A Copilot **cloud/IDE** agent — one under `.github/copilot/agents/` — may omit `name` entirely. + If it carries one, it still has to be kebab-case. +- `Use proactively` is meaningful in a CC description and steers the runtime to offer the agent + unprompted. In a Copilot description it does nothing; `KyberforgeCopilot.ProactivePhrase` flags + it. The Copilot equivalent is `disable-model-invocation` / `user-invocable`, which changes the + description contract entirely — see `references/description-quality.md`, Step 0. + +## Pair consistency + +Check that: + +- Both files exist. +- Both system prompt bodies are non-empty (`validate.sh` covers this; do it by hand only when the + script could not run). +- The two files describe the **same job**. Divergent capability claims across the pair mean one + half was edited and the other was not, which is the defect this dimension exists to catch. +- Descriptions may legitimately differ in *shape* when the Copilot half is hand-invoked — that is + the Step 0 case in `references/description-quality.md`, not a pair-consistency finding. + +Keep `pair-consistency` in the Step 4 coverage line at these scopes. diff --git a/plugins/kyberforge/.apm/skills/agent-audit/references/sources.md b/plugins/kyberforge/.apm/skills/agent-audit/references/sources.md index 3428f5d..748c4f8 100644 --- a/plugins/kyberforge/.apm/skills/agent-audit/references/sources.md +++ b/plugins/kyberforge/.apm/skills/agent-audit/references/sources.md @@ -14,7 +14,7 @@ source_keys: - **URL:** context7:/websites/code_claude - **Research doc:** plugins/kyberforge/docs/research/docs/claude-code-plugins/sources.md - **Description:** Official Claude Code documentation site indexed by Context7 — plugin manifest schema, subagent definition types, marketplace JSON format, agent markdown file format -- **Contributing files:** SKILL.md, references/field-inventory.md, references/description-quality.md +- **Contributing files:** SKILL.md, references/field-inventory.md, references/description-quality.md, references/body-and-delegation.md, references/scope-project-user.md - **Status:** `extracted` ## claude-code-plugins-docs @@ -22,7 +22,7 @@ source_keys: - **URL:** https://code.claude.com/docs/en/plugins - **Research doc:** plugins/kyberforge/docs/research/docs/claude-code-plugins/sources.md - **Description:** Official Claude Code plugin authoring guide — plugin structure, manifest fields, loading methods, skill namespacing, agent activation, marketplace submission -- **Contributing files:** SKILL.md, references/field-inventory.md, references/description-quality.md +- **Contributing files:** SKILL.md, references/field-inventory.md, references/body-and-delegation.md, references/scope-plugin-apm.md, references/validation-scripts.md - **Status:** `extracted` ## claude-code-subagents-docs @@ -30,7 +30,7 @@ source_keys: - **URL:** https://code.claude.com/docs/en/sub-agents - **Research doc:** plugins/kyberforge/docs/research/docs/claude-code-plugins/sources.md - **Description:** Official Claude Code subagent reference — definition format, all frontmatter fields, scope priority, built-in agents, CLI flags, environment variables, known limitations -- **Contributing files:** SKILL.md, references/field-inventory.md, references/description-quality.md +- **Contributing files:** SKILL.md, references/field-inventory.md, references/description-quality.md, references/body-and-delegation.md, references/scope-plugin-apm.md, references/scope-project-user.md, references/validation-scripts.md - **Status:** `extracted` ## context7-github-en-copilot @@ -38,7 +38,7 @@ source_keys: - **URL:** context7:/websites/github_en_copilot - **Research doc:** plugins/kyberforge/docs/research/docs/github-copilot-plugins/sources.md - **Description:** Official GitHub Copilot documentation indexed by Context7; covers CLI plugins, custom agents, SDK, and marketplace -- **Contributing files:** SKILL.md, references/field-inventory.md, references/description-quality.md +- **Contributing files:** SKILL.md, references/field-inventory.md, references/description-quality.md, references/body-and-delegation.md, references/scope-project-user.md - **Status:** `extracted` ## github-custom-agents-configuration @@ -46,7 +46,7 @@ source_keys: - **URL:** https://docs.github.com/en/copilot/reference/custom-agents-configuration - **Research doc:** plugins/kyberforge/docs/research/docs/github-copilot-plugins/sources.md - **Description:** Reference for cloud and IDE custom agent definition format — frontmatter fields, tool aliases, MCP server config, secrets interpolation, scoping hierarchy -- **Contributing files:** SKILL.md, references/field-inventory.md, references/description-quality.md +- **Contributing files:** SKILL.md, references/field-inventory.md, references/description-quality.md, references/body-and-delegation.md, references/scope-plugin-apm.md, references/scope-project-user.md, references/validation-scripts.md - **Status:** `extracted` ## github-cli-plugin-reference diff --git a/plugins/kyberforge/.apm/skills/agent-audit/references/validation-scripts.md b/plugins/kyberforge/.apm/skills/agent-audit/references/validation-scripts.md new file mode 100644 index 0000000..d6bc4ac --- /dev/null +++ b/plugins/kyberforge/.apm/skills/agent-audit/references/validation-scripts.md @@ -0,0 +1,70 @@ +--- +source_keys: + - claude-code-plugins-docs + - claude-code-subagents-docs + - github-custom-agents-configuration +--- + +# Validation Scripts Reference + +Read this when a Step 1 script fails, cannot run, or reports something that needs interpreting. +Nothing here is needed on a clean run. + +## Report the gap, do not guess + +If a script cannot run at all — Bash denied, `python3` unavailable, `vale` not installed — say so +as an **INFO** finding naming the script and the missing dependency, then fall back to the manual +checks below. An INFO never changes PASS/FAIL. Silently omitting the dimension a script would have +covered reports a clean audit that checked less than it claims to have checked. + +## How the scripts detect scope + +`validate.sh` and `validate-provenance.sh` walk up from the agent file's directory and stop at the +first of these: + +1. An `apm.yml` carrying a top-level `type: instructions|skill|hybrid|prompts` line — **plugin/APM + scope**, and that directory is the package root. An `apm.yml` with no `type:` is a + marketplace-only manifest: skip it and keep walking. +2. `$HOME` — **user scope**, checked before `.git` so a dotfiles-managed home directory that is its + own repo cannot shadow it. +3. A `.git` directory or file — **project scope**. +4. The filesystem root — **project scope**. + +`plugin.json` and `.claude-plugin/plugin.json` are not scope signals. A directory holding only a +`plugin.json` and no `apm.yml` falls through to project or user scope. + +`validate-provenance.sh` exits 0 silently when that walk does not land on a package root, and again +when the package has no provenance data. Silence from it is a pass, not a skip you need to +investigate. + +## Manual fallback + +**Every scope:** required fields present (`name`, `description`, non-empty body); `name` is +kebab-case; no `FILL IN:` placeholders in the description or body; the description at or under 400 +characters measured on the folded YAML value. + +**Plugin/APM scope:** `name` matches the filename stem; no HTML comments left in the frontmatter; +no frontmatter key outside the `apm-agent-allowlist` section of `references/field-inventory.md` — +open that file, do not work from memory. + +**Project/user scope:** the counterpart file exists; `name` matches the filename stem in the +Copilot `.agent.md` only (Claude Code files are exempt); no key from `claude-code-only-fields` in +the Copilot file and none from `copilot-only-fields` in the CC file, both read from +`references/field-inventory.md`. + +## Script-specific failures + +- **`Error: field-inventory.md not found` (exit 2).** `validate.sh` reads its field lists from + `references/field-inventory.md` at load time and refuses to run without it, rather than falling + back to a hardcoded list that could disagree with the file (ADR-0009). Restore the file; do not + work around it. +- **`vale` reports `0 files`.** Treat the pass as NOT RUN, not as clean, and fall back to full + Step 3 judgment for the dimensions it would have covered. The `Kyberforge` style is scoped to + `**/agents/*.md` and `**/*.agent.md`, and `KyberforgeCopilot` to `**/*.agent.md` alone — a file + outside those globs is silently not linted. +- **`E100 Runtime error ... does not exist` (exit 2) from `vale-wrap.sh`.** An explicit relative + `--config` was passed. Pass none: the wrapper locates its own `assets/vale/.vale.ini` from its + own path. Do not read this exit code as vale being unavailable. +- **A path argument that does not exist is a hard error** in `vale-wrap.sh`, deliberately: bare + `vale` would fall back to reading stdin and print a clean-looking `0 errors ... in stdin`, which + the `0 files` guard above does not catch. diff --git a/plugins/kyberforge/.apm/skills/agent-audit/scripts/validate.sh b/plugins/kyberforge/.apm/skills/agent-audit/scripts/validate.sh index 176cbba..fc0f711 100755 --- a/plugins/kyberforge/.apm/skills/agent-audit/scripts/validate.sh +++ b/plugins/kyberforge/.apm/skills/agent-audit/scripts/validate.sh @@ -36,12 +36,38 @@ if [[ $# -lt 1 ]]; then exit 1 fi +# PyYAML is a HARD dependency, not a nice-to-have. The description VALUE has to +# be measured after YAML folding is resolved, and the hand-rolled reader that +# used to stand in for PyYAML disagreed with it across the 400-character FAIL +# boundary — same description, two verdicts, depending on which reader ran. +# Refusing to start is the only honest option; the repo's jq / apm / vale +# dependencies are declared the same way. +# Check the interpreter separately from the library: `python3 -c` fails the same +# way whether python3 is missing or PyYAML is, and reporting the wrong missing +# dependency sends the reader to install the wrong thing. +if ! command -v python3 > /dev/null 2>&1; then + echo "Error: python3 is required but was not found on PATH." >&2 + echo " Why: skipping the ADR-0020 description and boundary-target gates would be a vacuous pass." >&2 + echo " Fix: install python3 (pre-commit itself is a Python application, so it is almost certainly already present)." >&2 + exit 1 +fi + +if ! python3 -c 'import yaml' > /dev/null 2>&1; then + echo "Error: PyYAML is required but is not importable by python3." >&2 + echo " Why: skipping the ADR-0020 description and boundary-target gates would be a vacuous pass." >&2 + echo " Fix: python3 -m pip install PyYAML (or your distro's python3-yaml package)." >&2 + exit 1 +fi + SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" python3 -u - "$1" "$SCRIPT_DIR" <<'PYTHON' import sys import os import re +import glob + +import yaml agent_file = os.path.abspath(sys.argv[1]) script_dir = sys.argv[2] @@ -65,8 +91,18 @@ if not os.path.isfile(inv_path): print(f"Error: field-inventory.md not found at {inv_path}", file=sys.stderr) sys.exit(2) -with open(inv_path) as f: - inv_content = f.read() +# Encoding is pinned to UTF-8 rather than inherited from the locale: under +# LC_ALL=C the inherited default is ASCII, and this file legitimately carries +# non-ASCII prose. read_text() in the shared resolver block below does the same +# thing for every other file; this one is read before that block is defined. +try: + with open(inv_path, encoding='utf-8') as f: + inv_content = f.read() +except UnicodeDecodeError as exc: + print(f"Error: field-inventory.md at {inv_path} is not valid UTF-8 " + f"({exc.reason} at byte {exc.start}) — re-save it as UTF-8.", + file=sys.stderr) + sys.exit(2) def parse_section_tokens(content, section_name): lines = content.splitlines() @@ -90,28 +126,826 @@ SUBAGENT_UNAVAILABLE_TOOLS = { # Copilot body length limit (chars) — content beyond this is silently truncated COPILOT_BODY_LIMIT = 30000 +# ADR-0020 description budget. An agent's name + description is preloaded into +# every session exactly like a skill's, so agents take the SAME description +# gates. These two constants are DUPLICATED from scripts/skill-size-check.sh +# and skill-audit/scripts/validate.sh rather than shared from one file: a +# cache-installed plugin's scripts cannot read files outside their own plugin +# directory, so there is no single source to share (same rationale as +# vale-wrap.sh's per-plugin duplication). tests/test-skill-size-check.sh +# asserts all copies agree, so drift fails CI rather than silently diverging. +# +# Agents deliberately take NO body word gate, and adding one here would +# contradict ADR-0020: a skill body is loaded into the caller's context and +# competes with the live conversation, while an agent body becomes the system +# prompt of a fresh context. The rationale for the 900-word skill ceiling does +# not transfer. Agent body length falls out of the delegation rule instead. +DESC_SUGGEST_CHARS = 250 +DESC_MAX_CHARS = 400 + # --- Helpers (shared by every scope) --- failed = False suggestions = [] def fail(msg): + # stderr, matching scripts/skill-size-check.sh's ERROR routing. All three + # scripts in the ADR-0020 family now agree: findings that fail the run go to + # stderr, everything advisory (SUGGESTION / INFO) goes to stdout. Both repo + # callers (check-apm-agents-valid.sh, check-scope-walkup-sync.sh) capture + # `2>&1`, so nothing a human reads moves. global failed failed = True - print(f"FAIL {msg}") + print(f"FAIL {msg}", file=sys.stderr) def suggest(msg): suggestions.append(msg) +def info(msg): + # A check that DECLINED to run says so out loud, rather than passing + # silently. Silence is what let a whole gate family go missing unnoticed. + print(f"INFO {msg}") + PLACEHOLDER_RE = re.compile(r'(?/plugins/*/ — sibling plugins resolve, +# which is what a monorepo means, +# 2. the target's own apm package, +# 3. the packages that package DECLARES in apm.yml dependencies.apm. +# Deployed .claude/ and .agents/ trees are deliberately NOT consulted when the +# root came from the plugins/ probe. They are `apm install` output, gitignored, +# and present only on a machine that has run it: four cross-plugin targets in +# this repo (gitea-branches -> git-branches, gitea-branches -> git-history, +# gitea-issues -> git-branches, gitea-workflow -> git-workflow) resolved through +# .claude/skills/ alone, so the same commit measured 2 dangling targets on a +# developer machine and 6 on a fresh clone. A gate shipping hot with no baseline +# cannot give two answers. +# +# Deployed trees ARE used when no plugin monorepo was found — whether the walk +# landed on a bare .git ancestor or on nothing at all. That is the consumer +# case: the file being checked lives in or beside a deployed tree, inside an +# ordinary git repo, with no monorepo to read. The two cases are told apart by +# which probe matched, never by how many names a root contributed; see +# known_targets(). + + +def _is_fs_root(path): + return os.path.dirname(path) == path + + +def _collect_package(pkg_dir, names): + """Add every skill/agent name a package directory exposes, any layout.""" + # glob.escape() the DIRECTORY only. A checkout path containing `[`, `]`, + # `*` or `?` — a worktree named `feature[2]`, say — otherwise turns the + # whole pattern into a character class that matches nothing, and the + # resolver degrades to the "DID NOT RUN" INFO with rc=0 across every file + # in the tree. The wildcards in `sub` are the intended ones and stay raw. + safe_dir = glob.escape(pkg_dir) + for sub in ('.apm/skills/*/', 'skills/*/'): + for path in glob.glob(os.path.join(safe_dir, sub)): + names.add(os.path.basename(path.rstrip('/')).lower()) + for sub in ('.apm/agents/*.md', 'agents/*.md'): + for path in glob.glob(os.path.join(safe_dir, sub)): + base = os.path.basename(path) + if base.endswith('.agent.md'): + base = base[:-len('.agent.md')] + else: + base = base[:-len('.md')] + names.add(base.lower()) + + +def _apm_package_root(start_dir): + """Nearest ancestor that is an apm package root (apm.yml or .apm/). + + The filesystem root is never a candidate: a stray /.apm/skills/ — a + scaffolding test's leftover, say, and one really does exist on at least one + machine here — would otherwise become the package root of every path on it. + Capped at ten levels so a pathological path can't become a filesystem + crawl; that covers every real layout by a wide margin. + """ + current = os.path.abspath(start_dir) + for _ in range(10): + if _is_fs_root(current): + return None + if (os.path.isfile(os.path.join(current, 'apm.yml')) + or os.path.isdir(os.path.join(current, '.apm'))): + return current + current = os.path.dirname(current) + return None + + +def _authoring_root(start_dir): + """Nearest ancestor that is a plugin monorepo, else the nearest .git tree. + + Returns (root, matched_plugins_probe). The flag reports WHICH probe + matched: True for the plugins/*/.apm/{skills,agents} glob, False for the + .git fallback and for no match at all. known_targets() needs that + distinction — only a real plugins/ root makes the deployed trees + redundant, and a name-count delta cannot tell the two apart. + + Two passes, not one interleaved walk: a nested .git (a submodule, a + worktree of a sub-package) must not win over a real plugins/ root further + up. Both passes stop before the filesystem root for the same reason + _apm_package_root does. + """ + probes = ( + lambda d: bool(glob.glob(os.path.join(glob.escape(d), 'plugins', '*', '.apm', 'skills')) + or glob.glob(os.path.join(glob.escape(d), 'plugins', '*', '.apm', 'agents'))), + lambda d: os.path.exists(os.path.join(d, '.git'))) + for index, probe in enumerate(probes): + current = os.path.abspath(start_dir) + for _ in range(12): + if _is_fs_root(current): + break + if probe(current): + return current, index == 0 + current = os.path.dirname(current) + return None, False + + +def _collect_authoring_root(root, names): + """Every plugin in the monorepo contributes its names.""" + for pkg in glob.glob(os.path.join(glob.escape(root), 'plugins', '*')): + if os.path.isdir(pkg): + _collect_package(pkg, names) + + +def _declared_dependency_dirs(pkg_dir): + """Directories of the apm packages pkg_dir's manifest DECLARES. + + Reads dependencies.apm and resolves each entry to a directory on disk: + a monorepo-relative `path:` (against the package root and the nearest + ancestor manifest, which is the monorepo root) or an installed + apm_modules//. Entries that resolve to nothing are skipped — an + undeployed dependency contributes no names rather than an error. + """ + manifest = os.path.join(pkg_dir, 'apm.yml') + if not os.path.isfile(manifest): + return [] + try: + data = yaml.safe_load(read_text(manifest)) or {} + except Exception: + return [] + if not isinstance(data, dict): + return [] + deps = data.get('dependencies') + deps = deps.get('apm') if isinstance(deps, dict) else None + if not isinstance(deps, list): + return [] + + roots = [pkg_dir] + ancestor = os.path.dirname(os.path.abspath(pkg_dir)) + for _ in range(10): + if _is_fs_root(ancestor): + break + if os.path.isfile(os.path.join(ancestor, 'apm.yml')): + roots.append(ancestor) + break + ancestor = os.path.dirname(ancestor) + + found = [] + for entry in deps: + candidates = [] + if isinstance(entry, dict): + rel = entry.get('path') + name = entry.get('name') + if not name and rel: + name = os.path.basename(str(rel).rstrip('/')) + if rel: + candidates.extend(os.path.join(r, str(rel)) for r in roots) + if name: + candidates.append(os.path.join(pkg_dir, 'apm_modules', str(name))) + elif isinstance(entry, str): + name = re.split(r'[#@]', entry)[0].strip().rstrip('/').split('/')[-1] + if name: + candidates.append(os.path.join(pkg_dir, 'apm_modules', name)) + candidates.extend(os.path.join(r, 'plugins', name) for r in roots) + for candidate in candidates: + if os.path.isdir(candidate): + found.append(candidate) + return found + + +def _deployed_roots(start_dir): + """.claude/ and .agents/ trees above start_dir — what a host really sees. + + Consulted ONLY when no plugin monorepo root was found; see the header. The + filesystem root is skipped for the same reason _apm_package_root skips it: + a stray /.claude/skills/ must not join every path's universe. + """ + found = [] + current = os.path.abspath(start_dir) + for _ in range(10): + if _is_fs_root(current): + break + for name in ('.claude', '.agents'): + base = os.path.join(current, name) + if os.path.isdir(base): + found.append(base) + current = os.path.dirname(current) + return found + + +def known_targets(start_dir): + """Every skill/agent name a boundary clause in start_dir may name.""" + names = set() + start = os.path.abspath(start_dir) + + # Siblings: a cache-installed plugin and a deployed .claude/skills/ tree + # both put peers one level up, with no plugins/ directory above them. The + # grandparent is guarded against the filesystem root exactly like the two + # walk-up loops above — for a start dir of /skills/ the grandparent is + # `/`, and collecting there picks up this machine's stray /.apm/skills/. + parent = os.path.dirname(start) + grandparent = os.path.dirname(parent) + if (os.path.basename(parent) in ('skills', 'agents') + and os.path.isdir(parent) and not _is_fs_root(grandparent)): + _collect_package(grandparent, names) + + package = _apm_package_root(start) + if package: + _collect_package(package, names) + for dep_dir in _declared_dependency_dirs(package): + _collect_package(dep_dir, names) + + # A .git ancestor is an authoring root only if it actually holds plugins. + # _authoring_root() falls back to the nearest .git, so it is truthy in ANY + # git repo; without the distinction that fallback wins in every consumer + # checkout, _collect_authoring_root() contributes nothing, and the deployed + # branch below is dead code in the exact case it exists for. So condition + # on WHICH probe matched, which _authoring_root() reports directly. A + # name-count delta looks equivalent and is not: _collect_authoring_root() + # re-collects the checked file's own plugin, whose names the blocks above + # already added, so a one-plugin monorepo shows a delta of zero and would + # wrongly reach for the deployed trees — including the user's global + # ~/.claude/skills, making the verdict depend on what happens to be + # installed (ADR-0020 lines 118-127). + root, root_has_plugins = _authoring_root(start) + if root: + _collect_authoring_root(root, names) + if not root_has_plugins: + for base in _deployed_roots(start): + _collect_package(base, names) + return names + + +# --- Extraction ----------------------------------------------------------- +# False positives are the design constraint here, not recall. The rules: +# * A BARE target must be hyphenated AND sit in a boundary sentence (one +# carrying "do not"/"instead"/"rather than"/"not for"). Without the second +# condition, pc-run's "run pre-commit hooks" reads as a route to a +# non-existent `pre-commit` skill. +# * A BARE arrow target counts only in ADR-0020's compressed boundary form, +# `Not -> `. Without that, diagnose's process chain +# "fix -> regression-test" reads as a route to `regression-test`. +# * A backticked hyphenated token counts only inside a boundary sentence. +# Unconditionally, `pre-push` or `commit-msg` in a TRIGGER clause is a hard +# FAIL with no escape hatch. Gating it costs nothing (measured over this +# corpus: 54 targets before and after); DELETING it costs 7 real targets +# across three gitea skills, so it is gated, not removed. +# * SINGLE-WORD targets are deliberately NOT matchable bare — `research`, +# `triage`, `forge`, `prototype` and `tdd` are all real skill names and all +# ordinary English, so a bare-word rule would flag most of the corpus. A +# single-word target must be written `` `forge` `` or /forge to be seen. +# That is a known recall limitation, accepted over the false positives. +# Tool names (Read/Write/Edit) are excluded by the lowercase-only pattern; MCP +# tool names (issue_write) by its rejection of underscores; file names by its +# rejection of dots and slashes. +# +# ATTRIBUTIVE USE. The boundary-sentence gate above does NOT solve the +# `pre-commit` false positive, and the comment that claimed it did was wrong: +# "instead", "rather than", "do not" and "not for" are exactly the words a +# boundary clause uses, so the gate is open precisely where the risk is. All of +# these were hard dangling FAILs with no suppression: +# Use pre-commit hooks instead of ad-hoc scripts. +# Invoke the pull-request template instead of writing one by hand. +# Use conventional-commits formatting rather than free-form messages. +# Composes label-resolution logic instead of duplicating it. +# Do not use for X — run the `pre-push` hooks instead. +# What separates every one of them from a real route is grammar, not marking: +# the hyphenated token is a compound MODIFIER of the noun that follows it +# ("pre-commit hooks", "pull-request template"), where a route target is +# terminal — followed by punctuation, a conjunction, or a boundary word. So a +# target whose next token is an ordinary lowercase noun is CONFIRM-ONLY: it +# still resolves and still counts as a route when the name exists, but it can +# never raise a dangling error on its own. +# +# This is deliberately NOT the simpler "only marked targets may dangle" rule, +# which would have been wrong here: BOTH live true positives in this corpus are +# BARE — research's "(use neuledge-context)" and gitea-issues' "Composes +# gitea-labels-\n milestones", where the `>` fold yields "gitea-labels- +# milestones" and the trailing hyphen is what keeps it terminal. Marking is a +# poor proxy, so the follower token is the signal, and it is applied to +# backticked targets too. +# +# TERMINAL IS NOT ENOUGH — IN-SENTENCE CORROBORATION. The follower test clears +# `pre-push` in the example above only because that example happens to be +# followed by the noun "hooks". Move the same token into terminal position and +# it was a hard FAIL again, with no suppression mechanism anywhere in this gate: +# Do not use for running hooks — run `pre-commit` instead. +# Do not use for the commit message — see `commit-msg`. +# Do not use for type errors — run `type-check` first. +# Instead, use `semantic-release`. +# Do not use for the old flow — use the clean-up instead. +# Do not run end-to-end, run unit-tests. +# Every one of those is grammatically identical to a genuinely broken route: +# "route verb + hyphenated name + terminal" is also exactly how prose cites a +# tool, a hook, a file format or an English compound. Nothing local separates +# them, and the skills most exposed are the ones this contract sends authors +# back to rewrite first — pc-run, pc-author, vale-run, vale-config and the apm-* +# family are all ABOUT hyphenated tools. +# +# So the confidence to BLOCK a commit comes from the sentence, not the token: a +# prose-form target may raise a hard error only when its own sentence names at +# least one OTHER target that RESOLVES. A routing sentence proves itself by +# routing somewhere real; a lone unresolvable name proves nothing. That is not a +# rule fitted to the fixtures — it is the shape of both live true positives, +# which sit beside `write-docs` and `gitea-labels-milestones` respectively, and +# it changes this corpus's verdict by exactly nothing. +# +# An uncorroborated unresolvable target is NOT discarded: every caller reports +# it at its SUGGESTION tier, naming the target. The finding stays visible on +# every run; only the power to block a commit is withdrawn, which is the part +# that had no escape hatch. +# +# EXPLICIT ROUTE NOTATION is exempt from corroboration and always blocks: +# ADR-0020's compressed arrow (`Not -> `) and Claude Code's +# invocation form (`/`). Neither is ever how English cites a tool — nobody +# writes `-> pre-commit` or `/pre-commit` to mean the hook — so there is no +# ambiguity to resolve, and an author who wants a route checked unconditionally +# has two ways to say so. +# +# NAMESPACE: `plugin:skill` is live in this repo (native user-scope installs +# still resolve `gitea:gitea-prs`), so the patterns admit an optional +# `:` prefix and normalize_target() strips it before resolution. +NS = r"(?:[a-z0-9]+(?:-[a-z0-9]+)*:)?" +NAME_ANY = NS + r"[a-z0-9]+(?:-[a-z0-9]+)*" +NAME_HYPH = NS + r"[a-z0-9]+(?:-[a-z0-9]+)+" +ROUTE_VERB = (r"(?:use|uses|using|run|runs|invoke|invokes|invoking|try|see" + r"|that'?s|compose|composes|call|calls" + r"|routes?\s+to|delegates?\s+to|prefers?|switch(?:es)?\s+to" + r"|hands?\s+off\s+to)") +MARKED_TARGET = r"(?:`/?(%s)`|(?|→)\s*%s" % MARKED_TARGET, re.I) +ARROW_BOUNDARY = re.compile(r"\bnot\b[^.;]*?(?:->|→)\s*(%s)\b" % NAME_HYPH, re.I) +BACKTICK = re.compile(r"`(%s)`" % NAME_HYPH, re.I) +# A boundary clause takes two shapes and BOTH count: the prose markers, and +# ADR-0020's compressed arrow form `Not -> `. +BOUNDARY_MARKER = re.compile(r"\b(?:do\s+not|instead|rather\s+than|not\s+for)\b", re.I) +BOUNDARY_ARROW = re.compile(r"\bnot\b[^.;]*?(?:->|→)", re.I) +# Sentence boundaries decide the CORROBORATION scope above, so getting one wrong +# is not cosmetic — it moves a target between SUGGESTION and blocking ERROR. Two +# shapes common in these descriptions defeat the naive "period, space, capital" +# rule, in OPPOSITE directions: +# OVER-SPLIT. `e.g. "set up the manifest"` ends no sentence, but the quote +# looks like one starting. The clause is cut in half, the corroborating +# target lands on the far side of the cut, and a genuinely dangling target +# silently demotes to SUGGESTION — the gate takes a measurement and then +# throws it away, which is the vacuous-green shape this file exists to stop. +# UNDER-SPLIT. A real sentence opening with a code span or a lowercase skill +# name ("... Composes it. `gitea-prs` also uses it.") is not seen as a start +# at all, so two sentences merge and a resolving target vouches for an +# unresolvable one it never stood beside — a hard FAIL with no escape hatch, +# which is exactly the failure the corroboration rule was added to prevent. +# Both are closed here: the five abbreviations that actually occur in routing +# prose are excluded as sentence ends, and the opener class admits a backtick or +# a lowercase letter. Verified zero-delta on the current corpus (37 ERROR / 58 +# SUGGESTION / 2 dangling before and after) — this protects the descriptions +# issue #99 is about to rewrite, not the ones already measured. +SENTENCE_SPLIT = re.compile( + u'(? name` (ADR-0020's compressed boundary + form, passed in by the caller that matched the arrow). A backticked name + does NOT qualify — a code span is how a tool, a file and a skill are all + cited, so it carries no intent the follower test hasn't already read. + """ + return arrow or (start > 0 and text[start - 1] == '/') + + +def _add(out, text, name, start, end, strict=None, arrow=False): + if not name: + return + out.append((name, + _terminal(text, end) if strict is None else strict, + _notation(text, start, arrow))) + + +def _scan(text, route_re, cont_re, out): + for match in route_re.finditer(text): + name, start, end = _first(match) + if not name: + continue + _add(out, text, name, start, end) + # "use git-history or git-branches instead" / "use gitea-issues / + # gitea-prs" — keep consuming conjoined targets after the first. + pos = match.end() + while True: + cont = cont_re.match(text, pos) + if not cont: + break + _add(out, text, *_first(cont)) + pos = cont.end() + + +def _extract_sentence(sentence): + """[(name, may_dangle, notation)] for the routing targets in ONE sentence. + + Kept separate from _extract() because corroboration is scoped to a single + sentence: a target's evidence is what stands beside it, not what the rest of + the description happens to mention. + """ + out = [] + boundary = bool(BOUNDARY_MARKER.search(sentence)) + _scan(sentence, + ROUTE_ANY if boundary else ROUTE_MARKED, + CONT_ANY if boundary else CONT_MARKED, + out) + for match in ARROW_MARKED.finditer(sentence): + # `-> name` and `-> /name` are route notation, not prose: nothing + # reads as a compound modifier after an arrow, so no follower test. + _add(out, sentence, *_first(match), strict=True, arrow=True) + for match in ARROW_BOUNDARY.finditer(sentence): + _add(out, sentence, match.group(1), match.start(1), match.end(1), + strict=True, arrow=True) + if boundary: + for match in BACKTICK.finditer(sentence): + _add(out, sentence, match.group(1), match.start(1), match.end(1)) + return out + + +def _extract(description): + """[(name, may_dangle, notation)] for every routing target.""" + out = [] + for sentence in SENTENCE_SPLIT.split(description): + out.extend(_extract_sentence(sentence)) + return out + + +def boundary_targets(description): + """Every routing target, for reporting and for confirming a route.""" + return sorted({name for name, _, _ in _extract(description)}) + + +def unresolved_targets(description, known): + """Targets resolving to nothing, split into (blocking, reported). + + `blocking` earns a hard error; `reported` is SUGGESTION tier — named on + every run, never fatal. Three conditions gate the promotion, and all of them + are documented at length in the ATTRIBUTIVE USE and CORROBORATION notes + above: + + 1. the target must be terminal, not a compound modifier ("pre-commit + hooks" is prose about a tool, not a route), + 2. it must be written in route notation (`/name`, `-> name`), OR + 3. its own sentence must name another target that DOES resolve. + + Everything else is reported and left alone. `known` is the resolved + universe from known_targets(); passing an empty set is not meaningful — + callers check for that first and decline out loud instead. + """ + blocking, reported = set(), set() + for sentence in SENTENCE_SPLIT.split(description): + found = _extract_sentence(sentence) + resolved = {normalize_target(name) for name, _, _ in found + if normalize_target(name) in known} + for name, may_dangle, notation in found: + key = normalize_target(name) + if key in known or not may_dangle: + continue + if notation or (resolved - {key}): + blocking.add(name) + else: + reported.add(name) + return sorted(blocking), sorted(reported - blocking) + +# --- Frontmatter ---------------------------------------------------------- +# Tolerant on the way in, HARD-FAILING on the way out. A UTF-8 BOM, a leading +# blank line, trailing whitespace after either `---`, or CRLF line endings all +# defeated the old `^---\n(.*?)\n---`, and the miss was SILENT: every ADR-0020 +# check was skipped and the file reported green (measured: a 550-character +# description with a 1,000-word body exited 0 behind a BOM). A file that cannot +# be measured must never report green, so every caller of these two ERRORs on a +# miss instead of moving on. +# +# The CLOSING marker is anchored at column 0 — deliberately NOT `[ \t]*---`. +# YAML block-scalar content must be indented deeper than its key, so an +# indented `---` inside a folded description is CONTENT; letting it close the +# frontmatter truncated the description mid-value and silently reclassified the +# rest as body, which is a vacuous green in both directions at once. Leading +# whitespace is still tolerated on the OPENING marker, where no such content +# can exist. +FRONTMATTER_RE = re.compile( + r'^[ \t\r\n]*---[ \t]*\r?\n(.*?)\r?\n---[ \t]*(?:\r?\n|\Z)', re.DOTALL) + + +def strip_bom(text): + return text[1:] if text.startswith(u'') else text + + +class FrontmatterError(Exception): + pass + + +def description_value(fm_text): + """The description VALUE, with YAML folding resolved. + + PyYAML is a HARD requirement, preflighted in bash. The hand-rolled fallback + this replaced diverged from a real parser across the FAIL boundary — one + corpus description measured 270 characters parsed and 412 unparsed, and a + quoted `"description"` key or an explicit `description: null` returned empty + from it, silently skipping the description AND routing checks. A gate that + disagrees with itself depending on which reader ran is worse than no gate. + + This is the ONLY reader any of the three scripts may use to decide whether a + description is present. A line regex cannot: `description:` with no value + followed by `model: sonnet` lets `\\s*` cross the newline and captures the + NEXT key, which reads as a non-empty description, skips the "missing or + empty" failure, and then early-returns out of every ADR-0020 gate on the + genuinely empty folded value. That combination exited 0 with zero output on + a BLOCKING pre-push gate. + """ + try: + data = yaml.safe_load(fm_text) + except Exception as exc: + # Every FrontmatterError message is a COMPLETE clause, never a detail a + # caller wraps in one. Callers used to prefix a hard-coded "frontmatter + # is not valid YAML (...)", which is true only of this branch: the two + # type failures below come from frontmatter that parsed fine, and + # telling their author the YAML is invalid sends them hunting for a + # syntax error that is not there — on a blocking gate with no baseline. + raise FrontmatterError('frontmatter is not valid YAML (%s)' + % re.sub(r'\s+', ' ', str(exc)).strip()) + if not isinstance(data, dict): + raise FrontmatterError('frontmatter is not a YAML mapping') + value = data.get('description') + if value is None: + return '' + if not isinstance(value, str): + # NOT str()-coerced. `description: true` became the 4-character "True" + # and sailed through the 400-character gate; a list or mapping was + # measured as its Python repr. Neither is a description a host can + # preload, so this is a parse failure, reported as one. + raise FrontmatterError( + 'description is a %s, not a string' % type(value).__name__) + return re.sub(r'\s+', ' ', value).strip() + + +# --- Body-shape checks (skills only; agents have no references/ dir) ------- +# Deterministic and countable, so they are enforced here. Whether a given +# gotcha is WARRANTED is semantic and stays the auditor's judgment, which is why +# both gotcha checks are SUGGESTION tier. A missing reference file is not a +# style opinion — it is a broken pointer — so that one is ERROR tier. +# +# Both read a FENCE-MASKED copy of the body. Scanning the raw body made a +# ```-fenced example a hard ERROR — and the skills most likely to carry one are +# skill-author and skill-audit, which DOCUMENT the references/ convention — and +# let a `## Gotchas` heading inside a fenced block stand in for the real +# section. Masking preserves every byte offset (content becomes spaces, +# newlines stay), so a span found in the mask slices the original. +GOTCHA_MAX_ENTRIES = 5 +GOTCHA_MAX_BODY_FRACTION = 0.25 +# The heading has to BE "Gotchas", not merely contain the word: `## Gotcha +# handling` and `## Why gotchas matter` are prose sections, and treating one as +# the Gotchas section measured a span that was never a gotcha list. +GOTCHA_HEADING = re.compile(r'^(#{1,6})[ \t]+(?:[^\n]*?[ \t])?gotchas?[ \t]*:?[ \t]*$', + re.I | re.M) +# Column 0 only. `^[ \t]{0,3}` counted a two-space-indented CHILD bullet as a +# top-level entry, so a five-entry section with sub-bullets reported nine. +GOTCHA_ENTRY = re.compile(r'^(?:[-*+]|\d+[.)])[ \t]+', re.M) +FENCE_OPEN = re.compile(r'^[ \t]{0,3}(`{3,}|~{3,})') +REFERENCE_POINTER = re.compile( + r'(?= len(fence) + and not stripped.strip()[len(marker):].strip()): + fence = None + # An UNCLOSED fence has no cost-free answer, only a choice of which way to + # be wrong. Masking to end-of-body blanks the rest of the body, silently + # disabling the ERROR-tier references/ check and the gotcha counts. + # Returning the raw text instead exposes the unclosed example's own + # content, so a fenced example naming a nonexistent references/ file + # becomes a hard ERROR it would not have been had the fence been closed — + # confirmed, not hypothetical. The loud-false-positive direction is the one + # chosen: this script's rule is that a file it cannot measure must never + # report green, and masking-onward is exactly that failure. Both outcomes + # need an already-malformed file, and the false positive costs one fence. + if fence is not None: + return text + return ''.join(out) + + +def gotcha_stats(body): + """(entry count, section word count) for the first Gotchas section, or None. + + The section runs to the next heading at the same level or shallower. + Entries are top-level list items; a section written as subheadings instead + of a list counts those. Headings and entries are read from the fence mask; + the word count is taken from the original slice, because fenced lines are + real body words and the fraction is measured against the whole body. + """ + masked = mask_fenced(body) + match = GOTCHA_HEADING.search(masked) + if not match: + return None + level = len(match.group(1)) + rest = masked[match.end():] + nxt = re.search(r'^#{1,%d}[ \t]+' % level, rest, re.M) + end = match.end() + (nxt.start() if nxt else len(rest)) + section = masked[match.end():end] + entries = len(GOTCHA_ENTRY.findall(section)) + if entries == 0 and level < 6: + entries = len(re.findall(r'^#{%d,6}[ \t]+' % (level + 1), section, re.M)) + return entries, len(body[match.end():end].split()) + + +def missing_reference_pointers(body, skill_dir): + """references/.md named in the body but absent from disk.""" + masked = mask_fenced(body) + missing = set() + for match in REFERENCE_POINTER.finditer(masked): + start = masked.rfind('\n', 0, match.start()) + 1 + end = masked.find('\n', match.end()) + if end < 0: + end = len(masked) + if REFERENCE_PAST.search(masked[start:end]): + continue + if REFERENCE_QUALIFIER.search(masked[start:match.start()]): + continue + if not os.path.isfile(os.path.join(skill_dir, 'references', match.group(1))): + missing.add('references/' + match.group(1)) + return sorted(missing) +# ===== END ADR-0020 SHARED BOUNDARY RESOLVER ===== + + def parse_frontmatter(content): - m = re.match(r'^---\n(.*?)\n---', content, re.DOTALL) + m = FRONTMATTER_RE.match(strip_bom(content)) if not m: return None, content - return m.group(1), content[m.end():] + return m.group(1), strip_bom(content)[m.end():] def extract_field(fm, field): - m = re.search(rf'^{re.escape(field)}:\s*(.+)', fm, re.MULTILINE) + """The raw text after `field:` ON ITS OWN LINE, or None. + + The character class is `[^\\S\\r\\n]`, never `\\s`: under re.MULTILINE a + `\\s*` after the colon crosses the newline, so `description:` with no value + followed by `model: sonnet` captured `model: sonnet` as the description. + That made the value look present, skipped the "missing or empty" failure, + and then every ADR-0020 gate early-returned on the genuinely empty folded + value — a valueless description exited 0 with zero output on a BLOCKING + pre-push gate. This function is now used only for fields with no folding + semantics (name, tools); description goes through description_value(), the + shared resolver's YAML reader, which is the only thing that can see through + `>`, `null`, `''` and a quoted `"description"` key alike. + """ + m = re.search(rf'^{re.escape(field)}:[^\S\r\n]*(.+)', fm, re.MULTILINE) return m.group(1).strip() if m else None def get_frontmatter_keys(fm): @@ -122,12 +956,110 @@ def get_frontmatter_keys(fm): keys.add(m.group(1)) return keys +def agent_description(fm, local_fname): + """The folded description VALUE, or None if it could not be read.""" + try: + return description_value(fm) + except FrontmatterError as exc: + # `exc` carries the whole clause — invalid YAML, a non-mapping block, or + # a description of the wrong type. Do not prefix a diagnosis here; the + # last one named a syntax error for two failures that have none. + fail(f"{exc} — the ADR-0020 description and boundary-target gates could " + f"not run — {local_fname}") + return None + +def check_description_budget(value, local_fname): + """ADR-0020 description gates — identical for every scope.""" + if not value: + return + dlen = len(value) + if dlen > DESC_MAX_CHARS: + fail(f"description is {dlen} chars — exceeds the {DESC_MAX_CHARS}-character " + f"ADR-0020 ceiling. It is preloaded into every session whether or not the " + f"agent is invoked. Keep a trigger clause, at most one capability clause, " + f"and a boundary clause; move capability enumeration, output-format detail, " + f"composition notes and implementation detail to the body — {local_fname}") + elif dlen > DESC_SUGGEST_CHARS: + suggest(f"description is {dlen} chars — over the {DESC_SUGGEST_CHARS}-character " + f"ADR-0020 target (hard fail at {DESC_MAX_CHARS}). The SUGGESTION tier is " + f"what moves the corpus average; the FAIL tier only stops outliers " + f"— {local_fname}") + +def check_boundary(value, fpath, local_fname): + """ADR-0020 boundary clause + resolvable boundary targets. + + agent-author's SKILL.md states that an agent's boundary targets must + resolve, but until this ran no script checked it — the contract was + documented and unenforced. The resolution universe is derived from the + AGENT FILE's own location (the authoring root above it, its own apm + package, and that package's declared apm dependencies), never from this + script's path, and — when an authoring root exists — never from a deployed + .claude/ tree, so a fresh clone and a machine that has run `apm install` + return the same verdict. + """ + if not value: + return + # SUGGESTION, not FAIL: detecting the absence is deterministic, but whether + # this particular agent warrants a boundary clause is judgment. All four + # agents in this corpus currently lack one. + if not has_boundary_clause(value): + suggest(f"description has no boundary clause — add the prose form (\"Do not use " + f"for X — use `y` instead\") or ADR-0020's compressed form (\"Not X -> y\") " + f"so the router knows where NOT to send this agent — {local_fname}") + targets = boundary_targets(value) + if not targets: + return + known = known_targets(os.path.dirname(os.path.abspath(fpath))) + if not known: + info(f"boundary-target resolution DID NOT RUN — no skill universe could be " + f"determined for this path (no authoring root above it, no apm package " + f"root, no declared apm dependencies, no deployed .claude/ or .agents/ " + f"tree). Unchecked target(s): {', '.join(targets)} — {local_fname}") + return + # blocking vs reported: a target only earns a FAIL when it is written in + # route notation or its own sentence corroborates it by naming another target + # that resolves. See the shared resolver's CORROBORATION note. + blocking, reported = unresolved_targets(value, known) + for target in blocking: + fail(f"description routes to '{target}', which resolves to no skill or agent " + f"in this monorepo, in this package, or in a package it declares in " + f"apm.yml dependencies.apm — a boundary clause naming a non-existent " + f"target sends the router nowhere — {local_fname}") + for target in reported: + suggest(f"description routes to '{target}', which resolves to no skill or agent " + f"in this monorepo, in this package, or in a package it declares in " + f"apm.yml dependencies.apm — SUGGESTION rather than FAIL because nothing " + f"else in that sentence resolves, so it is equally likely to be a tool, a " + f"file format or an English compound. If it IS a route, write it as " + f"`/{target}` or `-> {target}` and it will be checked properly — " + f"{local_fname}") + def extract_tools_list(fm): - """Extract tool names from the tools frontmatter field (space or comma separated).""" - val = extract_field(fm, 'tools') - if not val: + """Tool names from the `tools` field — inline scalar OR YAML block sequence. + + Read off the PARSED mapping, never off extract_field(). That function's + capture is newline-bounded on purpose (`[^\\S\\r\\n]*(.+)`), so a `tools:` + written as a block sequence — the shape Copilot agent files use — captured + nothing at all and the subagent-unavailable-tool check silently stopped + firing on exactly the files it was written for. Both spellings are legal + YAML, so both are read here. + """ + try: + data = yaml.safe_load(fm) + except Exception: + # Not this function's failure to report: the frontmatter's validity is + # decided (and failed) by agent_description() on the same text. return set() - return set(re.split(r'[\s,]+', val.strip())) + if not isinstance(data, dict): + return set() + val = data.get('tools') + if isinstance(val, list): + items = [str(item).strip() for item in val] + elif isinstance(val, str): + items = re.split(r'[\s,]+', val.strip()) + else: + return set() + return {item for item in items if item} def is_copilot_cloud_ide(fpath): """True if the file is a cloud/IDE Copilot agent (name is optional for these).""" @@ -146,7 +1078,10 @@ APM_TYPE_RE = re.compile(r"^type:\s*(['\"]?)(instructions|skill|hybrid|prompts)\ def find_apm_package_root(apm_yml_path): """Return True if apm_yml_path has a top-level type: line (i.e. is a package manifest, not a type:-less marketplace-only apm.yml).""" - with open(apm_yml_path) as f: + # errors='replace', not a hard failure: this only asks whether a `type:` + # line exists, and a stray undecodable byte elsewhere in someone else's + # apm.yml must not abort scope detection. + with open(apm_yml_path, encoding='utf-8', errors='replace') as f: for line in f: if APM_TYPE_RE.match(line): return True @@ -182,6 +1117,15 @@ def detect_scope(start_dir): conventional_root = os.path.dirname(os.path.dirname(original_start)) current = original_start while True: + # The filesystem root is never a candidate, the same guard the shared + # resolver's walk-up loops carry. Without it a file under a marker-less + # temp directory walked all the way to `/` and returned it as the scope + # root, which then reported `counterpart file not found: + # /.claude/agents/.md` — a path that names someone else's machine, + # not the user's project. When the walk runs out, the agent file's own + # directory (or its conventional root) is the honest answer. + if _is_fs_root(current): + return 'project', conventional_root if conventional_shape else original_start apm_yml = os.path.join(current, 'apm.yml') if os.path.isfile(apm_yml) and find_apm_package_root(apm_yml): return 'plugin', current @@ -224,12 +1168,28 @@ scope, scope_root = detect_scope(agent_dir) # --- Plugin/APM scope: single vendor-neutral file, no counterpart --- def check_apm_agent_file(fpath, allowlist, stem): local_fname = os.path.basename(fpath) - with open(fpath) as f: - content = f.read() + try: + content = read_text(fpath) + except EncodingError as exc: + fail(f"file is {exc}. Nothing could be measured, so this is a hard " + f"failure, not a skip — {local_fname}") + return + except OSError as exc: + # A path that cannot be opened gets a FAIL line naming it, not a bare + # FileNotFoundError traceback. scripts/check-apm-agents-valid.sh takes + # this path for an agent file deleted from the worktree but still + # tracked in the index — a real, expected state, and the caller needs to + # be told which file, not handed an interpreter stack. + fail(f"could not be read ({exc.strerror or exc}): {fpath}. Nothing could " + f"be measured, so this is a hard failure, not a skip — {local_fname}") + return fm, body = parse_frontmatter(content) if fm is None: - fail(f"no valid YAML frontmatter (---...---) — {local_fname}") + fail(f"no parseable YAML frontmatter block — expected a `---` line, the fields, " + f"then a closing `---` line (a BOM, leading blank lines, trailing spaces " + f"after either marker and CRLF endings are all tolerated). Nothing could be " + f"measured, so this is a hard failure, not a skip — {local_fname}") return # The apm-agent.md template embeds its authoring guidance as HTML @@ -267,12 +1227,21 @@ def check_apm_agent_file(fpath, allowlist, stem): fail(f"name '{name_val}' does not match filename stem '{stem}' — {local_fname}") # description — required, non-empty, no placeholder - desc_val = extract_field(fm, 'description') - if not desc_val: + # Presence is decided on the FOLDED value, never on a line regex. Deciding + # it on extract_field's raw capture is what let `description:` with no value + # pass this gate in total silence: the capture picked up the next key, so + # "missing or empty" never fired, and every ADR-0020 check below then + # early-returned on the empty folded value. Exit 0, zero output, no gate run. + folded = agent_description(fm, local_fname) + if folded is None: + pass # frontmatter is not valid YAML — agent_description already failed + elif not folded: fail(f"description field is missing or empty — {local_fname}") else: - if PLACEHOLDER_RE.search(desc_val): + if PLACEHOLDER_RE.search(folded): fail(f"description contains unfilled FILL IN: placeholder — {local_fname}") + check_description_budget(folded, local_fname) + check_boundary(folded, fpath, local_fname) # body — required, non-empty, no placeholder; same Copilot truncation risk # applies since this file compiles verbatim into a real Copilot file downstream. @@ -313,12 +1282,26 @@ else: # user def check_file(fpath, file_provider): local_fname = os.path.basename(fpath) - with open(fpath) as f: - content = f.read() + try: + content = read_text(fpath) + except EncodingError as exc: + fail(f"file is {exc}. Nothing could be measured, so this is a hard " + f"failure, not a skip — {local_fname}") + return + except OSError as exc: + # Same reason as check_apm_agent_file's: a diagnostic naming the path + # beats a FileNotFoundError traceback. The counterpart is pre-checked at + # the bottom of this script, but agent_file itself never was. + fail(f"could not be read ({exc.strerror or exc}): {fpath}. Nothing could " + f"be measured, so this is a hard failure, not a skip — {local_fname}") + return fm, body = parse_frontmatter(content) if fm is None: - fail(f"no valid YAML frontmatter (---...---) — {local_fname}") + fail(f"no parseable YAML frontmatter block — expected a `---` line, the fields, " + f"then a closing `---` line (a BOM, leading blank lines, trailing spaces " + f"after either marker and CRLF endings are all tolerated). Nothing could be " + f"measured, so this is a hard failure, not a skip — {local_fname}") return # name — required for CC and Copilot CLI; optional for Copilot cloud/IDE agents @@ -340,12 +1323,21 @@ def check_file(fpath, file_provider): fail(f"name '{name_val}' is not kebab-case — {local_fname}") # description - desc_val = extract_field(fm, 'description') - if not desc_val: + # Presence is decided on the FOLDED value, never on a line regex. Deciding + # it on extract_field's raw capture is what let `description:` with no value + # pass this gate in total silence: the capture picked up the next key, so + # "missing or empty" never fired, and every ADR-0020 check below then + # early-returned on the empty folded value. Exit 0, zero output, no gate run. + folded = agent_description(fm, local_fname) + if folded is None: + pass # frontmatter is not valid YAML — agent_description already failed + elif not folded: fail(f"description field is missing or empty — {local_fname}") else: - if PLACEHOLDER_RE.search(desc_val): + if PLACEHOLDER_RE.search(folded): fail(f"description contains unfilled FILL IN: placeholder — {local_fname}") + check_description_budget(folded, local_fname) + check_boundary(folded, fpath, local_fname) # body if not body.strip(): diff --git a/plugins/kyberforge/.apm/skills/agent-audit/tests/validate.bats b/plugins/kyberforge/.apm/skills/agent-audit/tests/validate.bats index f01993c..b58033e 100644 --- a/plugins/kyberforge/.apm/skills/agent-audit/tests/validate.bats +++ b/plugins/kyberforge/.apm/skills/agent-audit/tests/validate.bats @@ -31,6 +31,44 @@ description: A valid agent description. ${extra_frontmatter} --- +You are a test agent. When invoked, do the thing. +EOF + } + + # Helper: a description of EXACTLY characters that carries a boundary + # clause and names no routing target. ADR-0020's missing-boundary-clause + # SUGGESTION fires on any description without one, so a fixture that omits it + # is never "otherwise clean" and a test refuting SUGGESTION would be asserting + # the boundary check's absence instead of the thing it names. The clause is + # paid for out of the measured budget rather than appended to it, because + # these tests measure the description LENGTH. "anything else" is not + # hyphenated, so no routing target comes with it. + desc_of_length() { + python3 - "$1" <<'PY' +import sys +n = int(sys.argv[1]) +prefix = 'Use when doing the thing. Do not use for anything else. ' +assert n >= len(prefix), 'requested description shorter than the boundary clause' +print(prefix + 'x' * (n - len(prefix))) +PY + } + + # Helper: same shape as make_apm_agent, but the description is supplied + # verbatim — used by the ADR-0020 description-budget tests. + make_apm_agent_with_desc() { + local root="$1" name="$2" desc="$3" + mkdir -p "$root/.apm/agents" + cat > "$root/apm.yml" < "$root/.apm/agents/${name}.agent.md" < "$root/apm.yml" <` line it is 1 character and passes. + { + echo "---" + echo "name: my-agent" + echo "description: >" + python3 -c "print('\n'.join([' ' + 'x' * 40] * 11))" + echo "---" + echo "" + echo "You are a test agent." + } > "$root/.apm/agents/my-agent.agent.md" + run bash "$SCRIPT" "$root/.apm/agents/my-agent.agent.md" + assert_failure + assert_output --partial "description is 450 chars" +} + +@test "ADR-0020: the description gate applies at project scope too" { + local root="$TMPDIR/project" + local desc + desc="$(python3 -c "print('x' * 401)")" + mkdir -p "$root/.git" "$root/.claude/agents" "$root/.github/agents" + cat > "$root/.claude/agents/my-agent.md" < "$root/.github/agents/my-agent.agent.md" < "$root/apm.yml" < "$root/.apm/agents/my-agent.agent.md" + run bash "$SCRIPT" "$root/.apm/agents/my-agent.agent.md" + assert_success + refute_output --partial "FAIL" + # A 1,500-word body is 667% of the skill ceiling. Nothing may be said about + # it at any tier: not a FAIL, not a SUGGESTION, and not the word-count + # wording either tier would use if a gate were quietly added later. + refute_output --partial "SUGGESTION" + refute_output --partial "1500 words" + refute_output --partial "900-word" + refute_output --partial "body is" +} + @test "a bare plugin.json with no apm.yml is no longer plugin scope — falls through to project scope" { local root="$TMPDIR/proj-legacy-plugin-json" mkdir -p "$root/.git" "$root/.claude/agents" "$root/.github/agents" @@ -639,3 +810,136 @@ EOF assert_success refute_output --partial "hooks" } + +# --------------------------------------------------------------------------- +# tools: — both YAML spellings +# --------------------------------------------------------------------------- +# The subagent-unavailable-tool SUGGESTION is read off the `tools` field, and +# `tools` has two legal spellings: an inline scalar and a block sequence. The +# field used to be pulled out with a line regex whose capture is newline-bounded +# on purpose, so a block sequence captured NOTHING and the check silently +# stopped firing — on the shape Copilot agent files actually use, which is to say +# on the files it was written for. Both spellings are pinned, and they are pinned +# together: the inline case alone was green throughout. + +# make_pair — a project-scope CC + Copilot pair +# carrying the same `tools` value in both files. `tools` is on neither the +# claude-code-only nor the copilot-only list, so it is legal in both and the pair +# stays otherwise clean; the description carries a boundary clause so the only +# SUGGESTION that can fire is the one under test. +make_tools_pair() { + local root="$1" tools="$2" + mkdir -p "$root/.git" "$root/.claude/agents" "$root/.github/agents" + local f + for f in "$root/.claude/agents/my-agent.md" "$root/.github/agents/my-agent.agent.md"; do + { + echo "---" + echo "name: my-agent" + echo "description: A valid agent description. Do not use for anything else." + echo "$tools" + echo "---" + echo "" + echo "You are a test agent. When invoked, do the thing." + } > "$f" + done +} + +@test "a subagent-unavailable tool in an INLINE tools scalar raises a SUGGESTION" { + make_tools_pair "$TMPDIR/inline" "tools: Read ExitPlanMode" + run bash "$SCRIPT" "$TMPDIR/inline/.claude/agents/my-agent.md" + assert_success + assert_output --partial "'ExitPlanMode' is listed in tools but is never available to subagents" +} + +@test "a subagent-unavailable tool in a BLOCK SEQUENCE tools field raises the same SUGGESTION" { + make_tools_pair "$TMPDIR/block" "$(printf 'tools:\n - Read\n - ExitPlanMode')" + run bash "$SCRIPT" "$TMPDIR/block/.claude/agents/my-agent.md" + assert_success + assert_output --partial "'ExitPlanMode' is listed in tools but is never available to subagents" +} + +@test "a tools list with no subagent-unavailable tool stays silent in both spellings" { + # The control. Without it both cases above are satisfied by a check that + # fires on every tools field it can see, which would be the opposite defect. + make_tools_pair "$TMPDIR/inline-clean" "tools: Read Edit" + run bash "$SCRIPT" "$TMPDIR/inline-clean/.claude/agents/my-agent.md" + assert_success + refute_output --partial "never available to subagents" + + make_tools_pair "$TMPDIR/block-clean" "$(printf 'tools:\n - Read\n - Edit')" + run bash "$SCRIPT" "$TMPDIR/block-clean/.claude/agents/my-agent.md" + assert_success + refute_output --partial "never available to subagents" +} + +# --------------------------------------------------------------------------- +# A file that cannot be read +# --------------------------------------------------------------------------- +# scripts/check-apm-agents-valid.sh derives its expected agent-file set from +# `git ls-files`, so it hands this script paths that are tracked but absent from +# the worktree — a real and expected state, not a corner case. That used to exit +# 1 with a bare FileNotFoundError traceback and no FAIL line at all: non-zero, so +# the gate blocked, but with an interpreter stack instead of a diagnostic naming +# the file. Both scope paths are covered because they are separate call sites +# (check_apm_agent_file and check_file) and each needed its own handler. +# +# `is-a-dir.agent.md` is a DIRECTORY rather than a chmod 000 file on purpose: +# these tests run as root in CI, where mode bits do not deny anything and a +# permissions fixture would be silently readable and prove nothing. + +@test "a nonexistent plugin/APM-scope agent file gets a FAIL naming the path, not a traceback" { + local root="$TMPDIR/pkg" + mkdir -p "$root/.apm/agents" + cat > "$root/apm.yml" < "$root/apm.yml" < "$root/.github/agents/my-agent.agent.md" <.agent.md` at plugin/APM scope, or a Claude Code + Copilot CLI pair at project/user scope | -| `references/deployment-modes.md` | Plugin/APM vs project vs user scope: restrictions, scoped identifiers, path conventions | -| `references/scripts.md` | Conventions for new-agent.sh and any future scripts: contract, template variables, file placement, error messages | +| `references/create.md` | Create flow: prerequisites, scaffold and scope walk-up, what to fill in, package-root `sources.md` | +| `references/improve.md` | Improve flow: signal verification, root-cause grouping, generalizing, delegation over growth, ADR-0020 retrofit | +| `references/contract.md` | Description and body contract: three-part description shape, 250/400 tiers, delegation rule in place of a body word gate, invocation axis | +| `references/plugin-scope.md` | Plugin/APM scope field rules for the single vendor-neutral file, plus its pre-audit checklist | +| `references/project-user-scope.md` | Project/user scope field rules for the Claude Code + Copilot pair, both Copilot formats, plus its pre-audit checklist | +| `references/deployment-modes.md` | Scope hierarchy and precedence, scoped identifiers, cache isolation, path conventions | +| `references/scripts.md` | Conventions for new-agent.sh and the templates it copies: contract, template variables, file placement, error messages | | `references/sources.md` | Research provenance — sources that informed this skill | | `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) | diff --git a/plugins/kyberforge/.apm/skills/agent-author/SKILL.md b/plugins/kyberforge/.apm/skills/agent-author/SKILL.md index f43d4f1..c6ca310 100644 --- a/plugins/kyberforge/.apm/skills/agent-author/SKILL.md +++ b/plugins/kyberforge/.apm/skills/agent-author/SKILL.md @@ -1,18 +1,9 @@ --- name: agent-author description: > - Use when the user wants to create a new agent definition file from scratch - ("write an agent for X", "build a subagent that does Y", "create an agent - definition for Z"), or improve an existing one. Handles agent definitions at - plugin/APM, project, and user scope. Project and user scope always generate - a Claude Code (`.md`) + Copilot CLI (`.agent.md`) file pair in one pass; - plugin/APM scope generates a single vendor-neutral `.apm/agents/.agent.md` - file instead (no per-target Claude Code / Copilot split). Also use when the - user provides inline feedback about an agent's behavior and wants it applied, - or when a grill session has produced findings the user wants acted on — even - if they don't say "improve" explicitly. Do not use for read-only review — - examine agent files manually or run a grill session to generate improvement - signals. Do not use to author skills — use /skill-author instead. + Use when the user wants to create a new agent definition file from scratch, or + apply grill findings, audit findings, or inline feedback to an existing one. + Not read-only review -> `agent-audit`. Not skills -> `skill-author`. allowed-tools: Bash Read Write Edit metadata: category: factory @@ -20,240 +11,54 @@ metadata: - context7-websites-code-claude - claude-code-plugins-docs - claude-code-subagents-docs - - context7-github-en-copilot - - github-custom-agents-configuration - - github-cli-plugin-reference - - github-plugins-creating --- ## 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`, `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. -- Plugin agents in subdirectories get scoped identifiers (`plugin:folder:name`) — keep agents flat in `agents/` to avoid this. Applies to project/user-scope Claude Code agents only. -- Copilot CLI agent files **must** use the `.agent.md` extension — a plain `.md` file isn't picked up. The plugin/APM-scope single file also ends in `.agent.md` by convention, but it's vendor-neutral, not Copilot-only — it compiles to Claude Code too. -- Copilot has no `permissionMode`, `maxTurns`, `isolation`, or `memory` fields — do not include them in project/user-scope Copilot files. -- `model` resolution order for Claude Code: `CLAUDE_CODE_SUBAGENT_MODEL` env var → per-invocation parameter → frontmatter `model` → main session model. The frontmatter value is a low-priority default, not a guarantee. +- At plugin/APM scope `tools` and every Claude-only field are omitted entirely, not merely ignored: `apm compile` copies frontmatter verbatim to both harnesses, so fencing a read-only agent with `tools:` is wrong on one of them. `disallowedTools` is the one restriction that survives (ADR-0016). +- That fence is partial. It denies only the tools it names, never `Bash`, which a plugin-scope agent inherits — a shell redirect still writes. State the read-only boundary in the body too. +- An agent body carries no word gate; delegation replaces it. A plugin/APM agent is one file with no sibling `references/` directory, so it cannot disclose to itself, only invoke skills — and a body restating a procedure an invocable skill owns is an `agent-audit` FAIL. +- Duplicate `name` values in one scope: Claude Code discards one silently. Verify uniqueness before shipping. -## Route +## Step 1 — Dispatch -If the destination resolves to plugin/APM scope (scope detection in Step 1 finds a `type:`-bearing `apm.yml` at or above the root), read `references/deployment-modes.md`. +| Condition | Flow | Reference | +|---|---|---| +| No agent file at the target path(s) | Create | `references/create.md` | +| A file exists, at least one improvement signal present | Improve | `references/improve.md` | +| A file exists, no signals | Stop and ask | — | -Determine which flow before touching the filesystem: +Signals: grill output, `agent-audit` findings, inline feedback, session context describing what went wrong. With none, ask: "No improvement signals found. Did you mean to create a new agent, or do you have feedback to apply?" -- **Neither `.md` nor `.agent.md` exist at the target paths** → follow **Creating a new agent** -- **At least one file exists + improvement signals present** → follow **Improving an existing agent** -- **At least one file exists + no signals** → ask: "No improvement signals found. Did you mean to create a new agent, or do you have feedback to apply?" +Read only the reference for the resolved flow. Capture `git log --oneline -1` before touching the filesystem; Step 4 needs it. -Signals: grill session output, inline user feedback, session context describing what went wrong. +## Step 2 — Scope -## Creating a new agent +Scope decides which fields exist, so resolve it first. `scripts/new-agent.sh` walks up for a `type:`-bearing `apm.yml` and prints the scope it chose — read that output. -### Prerequisites +| Resolved scope | Emits | Read | +|---|---|---| +| plugin/APM | one vendor-neutral `.apm/agents/.agent.md` | `references/plugin-scope.md` | +| project or user | a Claude Code `.md` + Copilot `.agent.md` pair | `references/project-user-scope.md` | -Before touching the filesystem, confirm you have: -- [ ] Agent name (kebab-case, e.g. `code-reviewer`) -- [ ] Root directory (a path inside a package for plugin/APM scope, project root, or `~` for user scope) -- [ ] Agent purpose — one sentence describing the task this agent handles -- [ ] Trigger condition — when should the runtime delegate to this agent? +Read only the file for the resolved scope; the other describes fields this run cannot use. If precedence, cache isolation or path conventions matter, read `references/deployment-modes.md`. -If any are missing, stop and ask before proceeding. Then capture `git log --oneline -1` before touching the filesystem — Step 5 needs it to verify a real commit landed. +## Step 3 — Contract -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. +Before writing or editing a `description`, or restructuring a body, read `references/contract.md` — the three-part shape, banned content, the delegation rule and the body pattern. -### Step 1 — Scaffold +Gates `agent-audit` enforces at every scope: -Run the scaffold script with the agent name and root directory: +- **Description** — a trigger clause, at most one capability clause, and a boundary clause shaped `Not -> ` that resolves to a real skill or agent. 250 characters SUGGESTION, 400 FAIL, value only: an agent's `name` and `description` is preloaded into every session exactly as a skill's is. +- **Body** — no word gate, and a delegation check in its place: name the skill to invoke rather than restating what it does. +- **Invocation** — decide whether the agent is model-delegated or reached only by name. Only Copilot's cloud/IDE format expresses that in frontmatter (`disable-model-invocation`, `user-invocable`). -```bash -bash scripts/new-agent.sh -``` +At every scope, five tools reach no subagent whatever `tools` says — `AskUserQuestion`, `EnterPlanMode`, `ExitPlanMode`, `ScheduleWakeup`, `WaitForMcpServers`. Never write a body that has the agent ask the user a question or enter plan mode; it describes a turn the runtime cannot give it. -Examples: -```bash -bash scripts/new-agent.sh code-reviewer packages/my-package/ # plugin/APM scope if packages/my-package/apm.yml has a type: field -bash scripts/new-agent.sh deploy-assistant . -bash scripts/new-agent.sh security-reviewer ~ -``` +## Step 4 — Validate and close -**Scope detection (script handles this automatically).** The script walks up from `` for a package boundary — same shape `agent-audit`'s `validate.sh` uses: -- Nearest ancestor `apm.yml` with a top-level `type:` field (`instructions`/`skill`/`hybrid`/`prompts`) → **plugin/APM scope** → `/.apm/agents/.agent.md` (single vendor-neutral file). A `type:`-less `apm.yml` is marketplace-only — skipped, walk continues upward. -- No such `apm.yml`, `` is a project directory → **project scope** (unchanged) → `/.claude/agents/.md` + `/.github/agents/.agent.md` -- `` is exactly `~` (checked directly, no walk-up) → **user scope** (unchanged) → `~/.claude/agents/.md` + `~/.copilot/agents/.agent.md` +Invoke `agent-audit` on each file written and resolve every FAIL before reporting done. It checks the field allowlist, name-to-stem match, leftover placeholders and template comments, the description budget and the Copilot body limit — do not hand-check those. -A bare `plugin.json` with no `apm.yml` no longer signals plugin scope — that path is fully replaced, not dual-mode; it falls through to project scope. +At plugin/APM scope bump the resolved package's `apm.yml` `version` — **minor** on create, **patch** on improve — because consumers compare it to detect updates. Project and user scope have no manifest. -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: `/.apm/agents/.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 (`.md`); Step 3 covers the Copilot file. - -Open the scaffolded Claude Code file. Replace every `FILL IN:` placeholder. **Remove all template documentation comments from the YAML frontmatter after filling in required fields** — these are marked with `` and must be deleted before shipping. - -**`name`** — lowercase letters and hyphens only. Must be unique within the scope. - -**`description`** — the most important field for autonomous delegation: -- Start with an action verb: "Reviews...", "Analyzes...", "Generates..." -- If this agent should trigger without explicit user direction, include "Use proactively" in the description -- Specific about the triggering condition and expertise domain -- Under 300 characters preferred - -**`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. - -**`disallowedTools`** (all scopes, including plugin/APM) — denylist applied before `tools` and taking precedence over it; supports `mcp__`, `mcp____*`, 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 -- `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 -- `background`: set `true` to force background execution - -**`source_keys`** — top-level list of research source slugs that informed this agent. Add only when research sources were used (i.e. entries with `` `extracted` `` status are in context from a prior `/research` session). Each slug must match an H2 heading in `sources.md` — see Step 4 for where that file lives (plugin/APM scope only). Omit entirely when no research was used. - -```yaml -source_keys: - - my-source-slug -``` - -**System prompt body** — write as a direct role instruction: -- Open with: "You are a [role]. When invoked, [primary action]." -- Cover: inputs expected, process steps, output format, error handling -- One job per agent - -### Step 3 — Fill in the Copilot agent file (project/user scope only) - -Skip this step entirely at plugin/APM scope — there is no separate Copilot file there. The single `.apm/agents/.agent.md` file from Step 2 already compiles to both Claude Code and Copilot CLI via `apm compile`. - -**Two distinct Copilot agent formats** exist, with different paths and field sets. Choose one based on the deployment target: - -**CLI format** (default — what the scaffold creates): -- Path: `.github/agents/.agent.md` (project) or `~/.copilot/agents/.agent.md` (user) -- Extension: **must be `.agent.md`** -- Supported fields: `name` (required), `description` (required), `tools` (optional) -- `tools` uses Copilot aliases: `execute` (shell), `read`, `edit`, `search`, `agent`, `web` -- Body length limit: **30,000 characters** — content beyond this is silently truncated - -**Cloud/IDE format** (use when targeting Copilot Chat in VS Code or GitHub.com): -- Path: `.github/copilot/agents/.md` (note: plain `.md`, different directory) -- Additional fields available: `target` (`vscode`, `github-copilot`, or omit for both), `user-invocable` (set `false` to hide from manual invocation), `disable-model-invocation` (set `true` to require explicit user invocation), `mcp-servers` (MCP server config — processed by cloud runtime, ignored in VS Code) -- Body length limit: **30,000 characters** — silently truncated - -**Do not include Claude Code-only fields in either format**: `maxTurns`, `isolation`, `memory`, `permissionMode`, `effort`, `hooks`, `mcpServers`. - -**`source_keys`** — add the same top-level list as the CC file when research sources were used. Omit when no research was used. - -**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. - -### Step 4 — Populate or delete `sources.md` (plugin/APM scope only) - -Skip at project/user scope. The file lives at the package root (alongside `apm.yml`), not inside `.apm/agents/` — otherwise tooling that scans that directory for agent definitions would treat it as an agent needing frontmatter (ADR-0010). - -If a research `sources.md` is present in the conversation context: -1. Filter to entries with `` `extracted` `` status only. -2. For each entry, identify which agent file it contributed to. -3. Write `sources.md` at the package root using the format below. Paths in `Contributing files:` are relative to the package root. - -```markdown -# Sources - -## slug-name - -- **URL:** -- **Research doc:** -- **Description:** -- **Contributing files:** .apm/agents/.agent.md -- **Status:** `extracted` -``` - -Each slug must match an H2 heading, and each slug must also appear in the `source_keys` list of the file listed under `Contributing files:`. - -If no research sources are in context, delete `sources.md`. - -### Step 5 — Validate and close - -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 (`.agent.md`):** -- [ ] `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 (`.md`):** -- [ ] `name` unique in scope; `description` action-first - -**Project/user scope — Copilot CLI file (`.agent.md`):** -- [ ] File extension is `.agent.md` (not `.md`) -- [ ] `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`) -- [ ] Body does not exceed 30,000 characters - -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.** 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 - -### Step 1 — Verify inputs - -Confirm the agent files exist and at least one improvement signal is present in the conversation or a referenced file. - -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, 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 `, file-by-file no-op) then continue. Doesn't apply at plugin/APM scope — single file, no partial-pair state. - -### Step 2 — Gather and group signals - -Read the current agent file(s). Collect all signals from the conversation. - -Group by **root cause**, not symptom. One root cause → one fix. - -```text -Example: -- User feedback: agent keeps trying to push to remote -- Session context: no scope boundary in system prompt -→ Root cause: system prompt lacks git scope constraint → fix: add explicit boundary -``` - -### Step 3 — Announce planned changes - -Before editing, state which root causes were identified, what evidence supports each, and which files will change. Then proceed — edits are reversible via git. - -### 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 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. - -### Step 5 — Validate and close - -Re-run the validation checklist from the create flow's Step 5 on any edited file. - -At plugin/APM scope, apply a **patch bump** to the resolved package's `apm.yml` `version` (e.g. `1.0.4` → `1.0.5`). - -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.** 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. +**Commit verification.** Once the audit is clean, run `git add` and `git commit` — do not stop at staging. Re-run `git log --oneline -1` and confirm the hash changed from Step 1's. A non-empty `git diff --stat` is not proof: staged-but-uncommitted work is part of no commit and is lost if the tree is cleaned up. Report done only once the hash has changed. diff --git a/plugins/kyberforge/.apm/skills/agent-author/assets/README.md b/plugins/kyberforge/.apm/skills/agent-author/assets/README.md index bf24812..658ad7f 100644 --- a/plugins/kyberforge/.apm/skills/agent-author/assets/README.md +++ b/plugins/kyberforge/.apm/skills/agent-author/assets/README.md @@ -3,6 +3,7 @@ ## templates/ Annotated agent definition templates copied by `scripts/new-agent.sh` when scaffolding a new agent. +All three scaffold the `description` in the three-part ADR-0020 shape — a `Use when` trigger clause, at most one capability clause, and a boundary clause — rather than the deleted action-verb opener, and each carries a delegate-don't-restate note in the body. - **`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`). diff --git a/plugins/kyberforge/.apm/skills/agent-author/assets/templates/apm-agent.md b/plugins/kyberforge/.apm/skills/agent-author/assets/templates/apm-agent.md index 5e8bb8c..03783ce 100644 --- a/plugins/kyberforge/.apm/skills/agent-author/assets/templates/apm-agent.md +++ b/plugins/kyberforge/.apm/skills/agent-author/assets/templates/apm-agent.md @@ -27,11 +27,20 @@ name: AGENT_NAME -description: FILL IN: Action-first description of what this agent does and when to invoke it. - +description: FILL IN: Use when . Not -> . + + ## Inputs FILL IN: What inputs does this agent expect? (files, context, parameters) @@ -69,3 +83,10 @@ FILL IN: Steps the agent takes. Be specific about ordering if it matters. ## Output FILL IN: What does the agent produce? Format, location, structure. + +## Errors + +FILL IN: What does the agent do on malformed, missing or contradictory input? +State whether it stops and reports, or degrades to a named fallback — and what it +tells the caller either way. An agent with no error handling invents a recovery, +and an invented recovery is invisible until the output is wrong. diff --git a/plugins/kyberforge/.apm/skills/agent-author/assets/templates/claude-code.md b/plugins/kyberforge/.apm/skills/agent-author/assets/templates/claude-code.md index d4bc274..15a4175 100644 --- a/plugins/kyberforge/.apm/skills/agent-author/assets/templates/claude-code.md +++ b/plugins/kyberforge/.apm/skills/agent-author/assets/templates/claude-code.md @@ -7,21 +7,31 @@ name: AGENT_NAME -description: FILL IN: Action-first description of what this agent does and when to invoke it. - +description: FILL IN: Use when . Not -> . + + Listing any of them is a finding: agent-audit enforces the flat rule. --> + ## Inputs FILL IN: What inputs does this agent expect? (files, context, parameters) @@ -89,3 +104,10 @@ FILL IN: Steps the agent takes. Be specific about ordering if it matters. ## Output FILL IN: What does the agent produce? Format, location, structure. + +## Errors + +FILL IN: What does the agent do on malformed, missing or contradictory input? +State whether it stops and reports, or degrades to a named fallback — and what it +tells the caller either way. An agent with no error handling invents a recovery, +and an invented recovery is invisible until the output is wrong. diff --git a/plugins/kyberforge/.apm/skills/agent-author/assets/templates/copilot.agent.md.template b/plugins/kyberforge/.apm/skills/agent-author/assets/templates/copilot.agent.md.template index aead6f0..131aab0 100644 --- a/plugins/kyberforge/.apm/skills/agent-author/assets/templates/copilot.agent.md.template +++ b/plugins/kyberforge/.apm/skills/agent-author/assets/templates/copilot.agent.md.template @@ -12,10 +12,20 @@ name: AGENT_NAME -description: FILL IN: Action-first description of what this agent does and when to invoke it. - +description: FILL IN: Use when . Not -> . + + ## Inputs FILL IN: What inputs does this agent expect? (files, context, parameters) @@ -57,3 +72,10 @@ FILL IN: Steps the agent takes. Be specific about ordering if it matters. ## Output FILL IN: What does the agent produce? Format, location, structure. + +## Errors + +FILL IN: What does the agent do on malformed, missing or contradictory input? +State whether it stops and reports, or degrades to a named fallback — and what it +tells the caller either way. An agent with no error handling invents a recovery, +and an invented recovery is invisible until the output is wrong. diff --git a/plugins/kyberforge/.apm/skills/agent-author/references/README.md b/plugins/kyberforge/.apm/skills/agent-author/references/README.md index debded2..ef252d0 100644 --- a/plugins/kyberforge/.apm/skills/agent-author/references/README.md +++ b/plugins/kyberforge/.apm/skills/agent-author/references/README.md @@ -4,14 +4,52 @@ source_keys: [] # references/ +## create.md + +The create flow, loaded from SKILL.md Step 1 when no agent file exists at the target path. +Covers: prerequisites, the scaffold script and its scope walk-up, what to fill in at every scope, +and populating or deleting the package-root `sources.md`. + +## improve.md + +The improve flow, loaded from SKILL.md Step 1 when a file exists and at least one improvement +signal is present. Covers: signal verification, partial-pair recovery, root-cause grouping, +generalizing rather than patching, delegation over growth, and the ADR-0020 retrofit rule. + +## contract.md + +The description and body contract, loaded from SKILL.md Step 3 before any description is written +or any body restructured. Covers: the three-part description shape, banned description content, +boundary-target resolution, the 250/400 length tiers, the body role-instruction pattern, the +delegation rule that replaces a body word gate, and the invocation axis. + +## plugin-scope.md + +Field rules and the pre-audit checklist for the single vendor-neutral `.apm/agents/.agent.md` +file. Loaded from SKILL.md Step 2 when the scaffold resolves plugin/APM scope. + +## project-user-scope.md + +Field rules and the pre-audit checklist for the Claude Code `.md` + Copilot `.agent.md` pair, +including the two distinct Copilot formats. Loaded from SKILL.md Step 2 when the scaffold resolves +project or user scope. + ## deployment-modes.md -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. +Scope hierarchy and precedence, scoped identifiers for plugin subdirectory agents, cache isolation +behaviour, and Copilot CLI path conventions. Loaded from SKILL.md Step 2 when precedence, paths or +cache isolation matter to the run. ## scripts.md -Conventions for the `new-agent.sh` scaffold script and any future scripts added to this skill. Covers: what scripts should and should not do, file placement, error handling, template variable conventions, and the no-interactive-prompts rule. +Conventions for the `new-agent.sh` scaffold script, the templates it copies, and any future script +in this skill. Loaded from `create.md` Step 1 when the script or a template has to change. Covers: +the no-interactive-prompts rule, structured output, idempotency, template variables, file +placement, error messages, and the no-restated-field-roster rule that `tests/new-agent.bats` +enforces. ## sources.md -Research provenance record for this skill. Lists the upstream research sources (claude-code-plugins and github-copilot-plugins research docs) that informed SKILL.md, the templates, and the deployment-modes reference. Used by `skill-audit` to validate the provenance chain. +Research provenance record for this skill. Lists the upstream research sources +(claude-code-plugins and github-copilot-plugins research docs) that informed SKILL.md and the +reference files. Used by `skill-audit` to validate the provenance chain. diff --git a/plugins/kyberforge/.apm/skills/agent-author/references/contract.md b/plugins/kyberforge/.apm/skills/agent-author/references/contract.md new file mode 100644 index 0000000..50f6397 --- /dev/null +++ b/plugins/kyberforge/.apm/skills/agent-author/references/contract.md @@ -0,0 +1,149 @@ +--- +source_keys: + - claude-code-subagents-docs + - github-custom-agents-configuration +--- + +# The agent description and body contract + +House contract, set by ADR-0020. The counts and the boundary targets are enforced by +`agent-audit`'s `scripts/validate.sh`; the prose patterns by the Vale styles it bundles; the +judgment calls by its reference files. + +## Why the budget exists + +An agent's `name` and `description` is loaded into every session's context at startup, whether or +not the agent is ever delegated to — the same cost a skill's description carries, so agents take +the same numbers. The body is different: it is not loaded into the caller's conversation at all, +it *becomes the system prompt of a fresh context* when the agent runs. That is why the body has no +word gate here and a skill body has one. + +## Description + +A description carries exactly three things: + +1. **Trigger clause** — when to delegate, imperative: "Use when …", never "This agent …". Describe + the user's intent and the triggering condition, not the agent's internal mechanics. +2. **At most one capability clause** — what it does, one clause, no enumeration. Be specific + ("reviews a diff for injected credentials", not "helps with security"). +3. **Boundary clause** — form: `Not -> .` Add one only where a near-miss agent or + skill could steal delegations. + +Banned from a description; move it to the body or to `README.md`: + +- Capability enumeration or feature lists +- Per-scope emission mechanics — which files the author skill writes at which scope changes no + delegation decision +- Output-format detail ("Produces a compact findings report with Why and Fix per finding") +- Composition or architecture notes ("composes X rather than duplicating Y", "cross-cutting") +- Implementation detail ("Self-validates via a bundled deterministic script") +- Restating the same trigger twice in two registers — a verb list, then the same verbs re-quoted + as user phrasings. This is a FAIL, not a suggestion. + +**Do not open with an action verb.** "Reviews…", "Analyzes…", "Generates…" was the old house rule +and ADR-0020 deleted it: the opener is `Use when`, matching every skill in this corpus, so one +router reads one shape. + +**"Use proactively" is Claude Code-only, and conditional even there.** The phrase steers the +Claude Code runtime to offer an agent unprompted and does nothing anywhere else, so where it may +appear depends on the file: + +| File | Rule | +|---|---| +| Claude Code `.md` (project/user scope) | Allowed. Add it only where the runtime should delegate without the user naming the agent — an agent invoked by name does not need it, and it costs activations elsewhere when added by reflex. | +| Copilot `.agent.md` (project/user scope) | **Never.** Inert there, and `KyberforgeCopilot.ProactivePhrase` grades it a hard FAIL. | +| Vendor-neutral `.apm/agents/.agent.md` (plugin/APM scope) | **Never.** Same Vale rule, same hard FAIL — the file matches the `**/*.agent.md` glob, and it compiles to a real Copilot agent downstream. | + +A pair whose Claude Code half carries the phrase and whose Copilot half omits it is correct, not +inconsistent: `agent-audit` checks that both halves describe the same job, not that they match +word for word. + +Indirect triggers ("even if the user doesn't say X") take a similar conditional at every scope: +add one only where the user's natural phrasing genuinely omits the domain word. + +**Boundary targets must resolve.** Both forms are checked — the arrow and the prose form ("do not +use for X, use `y` instead") — so a typo dangles either way. Targets resolve against a universe +built by walking up **from the agent file itself**: the nearest ancestor holding +`plugins/*/.apm/{skills,agents}` (or, failing that, the nearest ancestor holding `.git`) contributes +every skill and agent under `/plugins/*/`, plus the agent's own apm package and the packages +that package declares in `apm.yml` under `dependencies.apm`. A sibling plugin in the same monorepo +therefore resolves; a skill in an unrelated repo does not. A target outside that universe sends the +router nowhere. Verify it before writing it — do not invent a plausible sibling. + +That universe is the apm marketplace and stops there. A **host built-in is not a routing target**: +`/compact`, `/clear` and `/init` are Claude Code slash commands with no counterpart in Copilot CLI +or Codex, and `.apm/` source compiles for all three, so routing to one is a portability defect. The +gate is right to fail it and there is no allowlist. If a built-in genuinely needs mentioning, write +it un-slashed — ``the `compact` built-in`` — which makes no routing claim and is not checked. + +**Length.** 250 characters SUGGESTION, 400 characters FAIL, counting the frontmatter value only +with YAML folding resolved. Treat 250 as the target: the SUGGESTION tier is what moves the corpus +average, the FAIL tier only stops outliers. + +## Body + +Write the body as a direct role instruction, addressed to the agent: + +````markdown +You are a . When invoked, . + +## Inputs + + +## Process + + +## Output + + +## Errors + +```` + +Four required elements: **inputs expected, process steps, output format, error handling.** The +last is the one that gets dropped, and dropping it is not neutral: an agent given a malformed +input and no instruction invents a recovery, and a subagent's invented recovery is invisible to +the caller until the output is wrong. Say explicitly whether the agent stops and reports, or +degrades to a named fallback. + +One job per agent. An agent covering two jobs gets delegated to for the wrong one. + +**Delegation discipline replaces the word gate.** A plugin/APM agent is a single file with no +sibling `references/` directory: it cannot disclose progressively to itself, so its only way to +stay short is to *invoke* rather than *restate*. A body that transcribes a procedure a skill it +can invoke already owns is an `agent-audit` FAIL, and the fix is one line — "invoke ``". + +- Restating: "To commit, check the message against Conventional Commits: type, scope, + description; header under 100 chars; …" +- Delegating: "Author commits with `git-commits`." + +The same holds for a procedure another agent owns. What belongs in the body is what no invocable +skill covers: the agent's role, its boundaries, the order it works in, and the format it returns. + +**State a read-only boundary in prose, not only in frontmatter.** `disallowedTools` denies the +tools it names and nothing else — never `Bash`, which an agent with no `tools` field inherits — so +an agent fenced only in frontmatter can still write through a shell redirect. + +## Invocation axis + +Decide before writing the description whether the agent is model-delegated (the runtime picks it) +or reached only by name (`@agent-`). + +Only Copilot's cloud/IDE format expresses that in frontmatter: `disable-model-invocation: true` +requires explicit invocation, and `user-invocable: false` hides an agent from manual invocation. +Both live in `.github/copilot/agents/.md` and are inert in the CLI format. Claude Code has +no equivalent field, and neither does the vendor-neutral plugin/APM file, so at those scopes a +name-invoked agent still needs a description precise enough not to steal delegations — the +boundary clause is doing that work. + +## One gate, two measurements + +| Gate | SUGGESTION | FAIL | Counts | +|---|---|---|---| +| description | 250 chars | 400 chars | the `description:` value only | +| body (Copilot limit) | 30,000 chars | — | the body only; content past it is truncated silently | + +The 30,000-character Copilot ceiling is a runtime truncation limit, not a quality target, and it +applies to a plugin/APM file too — that file compiles into a real Copilot agent downstream. An +agent body long enough to approach it has a delegation defect, not a length problem. diff --git a/plugins/kyberforge/.apm/skills/agent-author/references/create.md b/plugins/kyberforge/.apm/skills/agent-author/references/create.md new file mode 100644 index 0000000..369a36e --- /dev/null +++ b/plugins/kyberforge/.apm/skills/agent-author/references/create.md @@ -0,0 +1,93 @@ +--- +source_keys: + - context7-websites-code-claude + - claude-code-subagents-docs + - github-plugins-creating +--- + +# Creating a new agent + +Return to `SKILL.md` Step 4 once Step 3 below is done — validation, the version bump and commit +verification are shared with the improve flow and are not repeated here. + +## Prerequisites + +Before touching the filesystem, confirm you have: + +- [ ] Agent name (kebab-case, e.g. `code-reviewer`) +- [ ] Root directory — a path inside a package for plugin/APM scope, a project root, or `~` for + user scope +- [ ] Agent purpose — one sentence describing the task this agent handles +- [ ] Trigger condition — when should the runtime delegate to this agent? + +If any are missing, stop and ask before proceeding. + +`agent-audit` runs the validation in `SKILL.md` Step 4. It ships with the kyberforge plugin and +is co-installed with this skill; if it is unavailable, stop and ask the user to install +kyberforge before continuing. + +Design for one job per agent. An agent covering two jobs is delegated to for the wrong one. + +## Step 1 — Scaffold + +```bash +bash scripts/new-agent.sh +``` + +Examples: + +```bash +bash scripts/new-agent.sh code-reviewer packages/my-package/ # plugin/APM scope if packages/my-package/apm.yml has a type: field +bash scripts/new-agent.sh deploy-assistant . +bash scripts/new-agent.sh security-reviewer ~ +``` + +The script resolves scope itself and prints which one it used and every path it wrote — read that +output rather than predicting it. It walks up from `` for the nearest ancestor `apm.yml` +carrying a top-level `type:` field (`instructions`/`skill`/`hybrid`/`prompts`), which marks a +package root and means plugin/APM scope. An `apm.yml` with no `type:` is a marketplace-only +manifest: the walk skips it and keeps going. With no such manifest found, `` being exactly +`~` (checked directly, no walk-up) is user scope and anything else is project scope. A bare +`plugin.json` no longer signals plugin scope — that path was replaced outright, not made +dual-mode, and falls through to project scope. + +The script is file-by-file no-op: it skips any file that already exists, so re-running it to +complete a partial pair is safe. + +If the script or a template under `assets/templates/` has to change to support this agent — a new +scope, a new scaffolded field, different output — read `references/scripts.md` first. Its +conventions are asserted by `tests/new-agent.bats`, and an edit that ignores them fails the suite. + +## Step 2 — Fill in the file(s) + +Take the scope the script reported and read the matching reference — `SKILL.md` Step 2 has the +table. That file carries the field rules and the pre-audit checklist for this scope; the other one +describes fields this run cannot use. + +Every scaffolded file, at every scope: + +1. Replace each `FILL IN:` placeholder. +2. Delete every `` template comment from the frontmatter. `apm compile` copies plugin + frontmatter verbatim and HTML comments are not valid YAML, so a leftover comment breaks the + file downstream on both harnesses. +3. Write the `description` against `references/contract.md` and the system prompt body against its + Body section. + +## Step 3 — Populate or delete `sources.md` + +Plugin/APM scope only — skip at project and user scope, which have no package root to hold the +file. + +The scaffold writes a commented `sources.md` skeleton at the package root, alongside `apm.yml` and +not inside `.apm/agents/`, so that tooling scanning that directory for agent definitions does not +treat it as an agent missing its frontmatter (ADR-0010). + +If a research `sources.md` is present in the conversation context, filter it to entries with +`` `extracted` `` status, work out which agent file each one contributed to, and fill in the +skeleton following the commented format already in the file. Paths in `Contributing files:` are +relative to the package root. Each slug must match an H2 heading and must also appear in the +`source_keys` list of every file named under its `Contributing files:`. + +If no research sources are in context, delete `sources.md`. + +Then return to `SKILL.md` Step 4. diff --git a/plugins/kyberforge/.apm/skills/agent-author/references/deployment-modes.md b/plugins/kyberforge/.apm/skills/agent-author/references/deployment-modes.md index b536c39..d067eda 100644 --- a/plugins/kyberforge/.apm/skills/agent-author/references/deployment-modes.md +++ b/plugins/kyberforge/.apm/skills/agent-author/references/deployment-modes.md @@ -22,13 +22,14 @@ Agent definitions deploy at three scopes and behave differently at each. The sco When the same agent `name` appears at multiple scopes, **user scope wins over project scope wins over plugin scope** in Claude Code. In Copilot CLI, repo-level agents override enterprise and org-level; home-directory (user) agents override repo-level on name collision. -## Plugin scope restrictions +## Which fields exist where -Plugin/APM agents (`.apm/agents/.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). - -**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. +Field rules are per scope and live with the scope: `references/plugin-scope.md` for the single +vendor-neutral file, `references/project-user-scope.md` for the Claude Code / Copilot pair. Read +one, not both. The short version is that plugin/APM frontmatter is an allowlist read from +`agent-audit`'s `references/field-inventory.md`, narrow because `apm compile` copies frontmatter +verbatim to every target (ADR-0016), while project and user scope carry the full per-provider +field sets. ## Scoped identifiers (Claude Code plugin agents only) diff --git a/plugins/kyberforge/.apm/skills/agent-author/references/improve.md b/plugins/kyberforge/.apm/skills/agent-author/references/improve.md new file mode 100644 index 0000000..0aef172 --- /dev/null +++ b/plugins/kyberforge/.apm/skills/agent-author/references/improve.md @@ -0,0 +1,87 @@ +--- +source_keys: + - claude-code-subagents-docs +--- + +# Improving an existing agent + +Return to `SKILL.md` Step 4 once Step 4 below is done — validation, the version bump and commit +verification are shared with the create flow and are not repeated here. + +## Step 1 — Verify inputs + +Confirm the agent file (or, at project and user scope, the pair) exists and that at least one +improvement signal is present in the conversation or in a referenced file. + +If no signals are present, stop: "This skill applies existing signals to an agent. For a blind +review, run `agent-audit` instead." + +`agent-audit` runs the validation in `SKILL.md` Step 4 and is co-installed with this skill; if +it is unavailable, stop and ask the user to install the kyberforge plugin before continuing. + +**Partial pair — project and user scope only.** If one provider file exists and the other does +not, scaffold the missing one with `bash scripts/new-agent.sh ` (file-by-file no-op) +and continue. Plugin/APM scope is a single file and has no partial state. + +## Step 2 — Gather and group signals + +Read the current file(s), then collect every signal from the conversation and from any path the +user referenced. + +Group signals by **root cause**, not by symptom. Patching per symptom is the default failure mode: +three complaints often trace to one missing instruction. Ask: "What single gap in this agent +causes this cluster?" One root cause, one fix. + +```text +Example: +- User feedback: the agent keeps trying to push to the remote +- Session context: no scope boundary in the system prompt +→ Root cause: the system prompt has no git scope constraint → fix: add an explicit boundary +``` + +## Step 3 — Announce planned changes + +Before editing, state which root causes were identified, what evidence supports each, and which +files will change. Then proceed — edits are reversible via git, so no approval checkpoint is +needed. + +## Step 4 — Apply changes + +Edit whichever file the signals point to. + +**Generalize, do not patch.** Fix the underlying gap, not the one example that failed. A fix +scoped to the cases you have seen overfits and performs worse on new input. + +**Delegate rather than grow.** An agent body has no word ceiling, but a body that restates a +procedure a skill it can invoke already owns is an `agent-audit` FAIL. When a signal reports a +missing procedure, check first whether an installed skill owns it and name that skill instead of +transcribing it. See `references/contract.md`. + +The delegation check is not a length brake — it fires only on procedure an invocable skill already +owns, and says nothing about original prose. That brake is judgment, and it is the only one left: +for every sentence you add, ask "would the agent get this wrong without it?" and delete it if the +answer is no. + +**Explain the why.** Reasoning-based instructions outperform rigid directives. A rule written in +all caps (ALWAYS/NEVER) is usually better reframed as why the behaviour matters, so the agent can +apply judgment at the edges. + +**Retrofit before extending.** Any agent predating ADR-0020 has to meet the description contract +before any other edit lands — the gates are hot and carry no baseline file, so a one-line fix to a +non-compliant agent cannot be committed until its description meets `references/contract.md`. +Treat that retrofit as part of the same change, not a follow-up. + +**Re-check the scope rules.** Read the reference for the resolved scope (`SKILL.md` Step 2) and +confirm the edit introduced no field that scope forbids, and dropped no `disallowedTools` fence +that was already there. + +If the edit adds or removes research-sourced content, update `source_keys` in the edited file and +the matching `sources.md` entry — the create flow's Step 3 has the rules. + +**Check for regressions before handing back.** `SKILL.md` Step 4 tells you to resolve every FAIL, +which says nothing about a check that passed *before* these edits and no longer does. Compare the +closing `agent-audit` against the agent's pre-edit state — a PASS that has become a SUGGESTION, or +a SUGGESTION that has become a FAIL, is damage this flow caused and is in scope for it. Only the +improve flow can make that comparison; the create flow has no prior state to compare against. + +Then return to `SKILL.md` Step 4. diff --git a/plugins/kyberforge/.apm/skills/agent-author/references/plugin-scope.md b/plugins/kyberforge/.apm/skills/agent-author/references/plugin-scope.md new file mode 100644 index 0000000..3d71359 --- /dev/null +++ b/plugins/kyberforge/.apm/skills/agent-author/references/plugin-scope.md @@ -0,0 +1,74 @@ +--- +source_keys: + - context7-websites-code-claude + - claude-code-plugins-docs +--- + +# Plugin/APM scope — the single vendor-neutral file + +One file, no counterpart: `/.apm/agents/.agent.md`. `apm compile` emits it to +both the Claude Code and the Copilot CLI target. The `.agent.md` extension here is convention, not +a Copilot marker — the file is vendor-neutral. + +## Frontmatter + +The permitted keys are the `apm-agent-allowlist` section of `agent-audit`'s +`references/field-inventory.md`. Read them from there as data — that section is the single source +of truth, `agent-audit`'s `validate.sh` parses it at load time, and it changes. Any restatement of +the roster, here or in a template or in script output, goes stale one step further out than the +list itself. + +- `name` — kebab-case, must equal the filename stem, unique within the scope. +- `description` — write it against `references/contract.md`. +- Everything else — check the allowlist section before adding a key. A key outside it fails the + audit. + +**Why the list is narrow.** `apm compile` copies frontmatter verbatim to every target with no +per-target integrator, so a harness-specific value is wrong on at least one of them (ADR-0016). +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 one value + is wrong on one target. It stays out. Omitting it means inherit-all-tools on both, which is + never wrong. +- `disallowedTools` is a **denylist**, and denying by name cannot fail that way: a name the other + harness does not recognise denies nothing, so the worst case is a missing fence, never a wrongly + granted capability. That asymmetry is the whole exception (ADR-0016's 2026-08-14 amendment). + Claude Code honours it for plugin subagents; the three fields plugin agents do silently ignore + are `hooks`, `mcpServers` and `permissionMode`, and this is not one of them. Copilot's handling + of the key is unconfirmed, which ADR-0016 accepts as a stated risk. + + Its syntax is the same at every scope, and this is the one scope that cannot reach it anywhere + else: MCP tools are denied as `mcp__`, `mcp____*` or `mcp__*`; both a YAML list + and a delimited string are accepted, and this repo writes the comma-separated string form + (`disallowedTools: Edit, Write, NotebookEdit`) — match it. +- The Claude-only knobs (`isolation`, `maxTurns`, `effort`, `memory`, `permissionMode`, `skills`, + `color`, `initialPrompt`, `background`, `hooks`, `mcpServers`) have no Copilot equivalent and + are never written to this file at all. "Silently ignored at plugin scope" is the wrong framing: + they are absent, not tolerated. To use any of them, copy the agent to `.claude/agents/` + (project scope) or `~/.claude/agents/` (user scope). + +Write `disallowedTools` on every read-only plugin-scope agent — and say the agent is read-only in +the body as well, because the fence does not cover the inherited `Bash` tool. + +`source_keys` is provenance metadata, not a runtime field: both harnesses ignore it. Add it only +when research sources informed the agent, with slugs matching H2 headings in the package root's +`sources.md`. + +## Body + +Follow the Body section of `references/contract.md`: role instruction, one job, and delegation +to installed skills instead of transcribed procedure. + +## Before invoking `agent-audit` + +- [ ] `name` kebab-case, matching the filename stem, unique in scope +- [ ] `description` written to `references/contract.md` +- [ ] Every frontmatter key present in the `apm-agent-allowlist` section — in particular no `tools` +- [ ] No `FILL IN:` placeholder and no `` template comment anywhere in the file +- [ ] System prompt body non-empty, and a read-only agent says so in prose as well as in + `disallowedTools` +- [ ] Body covers all four required elements: inputs expected, process steps, output format, + **error handling** — what the agent does on malformed, missing or contradictory input + +Then return to the flow reference you came from. diff --git a/plugins/kyberforge/.apm/skills/agent-author/references/project-user-scope.md b/plugins/kyberforge/.apm/skills/agent-author/references/project-user-scope.md new file mode 100644 index 0000000..0e63019 --- /dev/null +++ b/plugins/kyberforge/.apm/skills/agent-author/references/project-user-scope.md @@ -0,0 +1,111 @@ +--- +source_keys: + - claude-code-subagents-docs + - context7-github-en-copilot + - github-custom-agents-configuration + - github-cli-plugin-reference +--- + +# Project and user scope — the Claude Code / Copilot pair + +Two files per agent, written in one pass and kept in step: a Claude Code `.md` and a Copilot CLI +`.agent.md`. The system prompt body is the same in both — the agent's task does not change with +the provider. The frontmatter is not. + +| Scope | Claude Code | Copilot CLI | +|---|---|---| +| Project | `.claude/agents/.md` | `.github/agents/.agent.md` | +| User | `~/.claude/agents/.md` | `~/.copilot/agents/.agent.md` | + +## Claude Code file + +**`name`** — lowercase letters and hyphens only, unique within the scope. Claude Code discards a +duplicate silently. + +**`description`** — write it against `references/contract.md`. It is the primary signal for +autonomous delegation. + +**`tools`** — an allowlist. Write it, and restrict it to the tools the agent actually needs; +omitting it inherits every tool from the parent, which is the right value only when the agent +genuinely needs all of them. Least privilege is the default, not the exception. Use +`Agent(type1,type2)` +to restrict which subagent types this agent may spawn, and omit `Agent` entirely to stop it +spawning any. Five tools reach no subagent whatever this field says — `AskUserQuestion`, +`EnterPlanMode`, `ExitPlanMode`, `ScheduleWakeup` and `WaitForMcpServers` — so listing one buys +nothing. The single exception is `ExitPlanMode`, available when the parent session runs +`permissionMode: plan`. + +**`disallowedTools`** — a denylist, applied before `tools` and taking precedence over it. Supports +`mcp__`, `mcp____*` and `mcp__*` globs. Both a YAML list and a delimited string +are accepted; this repo writes the comma-separated string form (`disallowedTools: Edit, Write, +NotebookEdit`) — match it. + +**`model`** — set it when the agent needs a different capability tier (`haiku` for fast lookups, +`opus` for deep reasoning). Resolution order is `CLAUDE_CODE_SUBAGENT_MODEL` → the per-invocation +parameter → this field → the main session model, so the frontmatter value is a low-priority +default rather than a guarantee. + +Optional fields worth considering, none of which exist at plugin/APM scope: + +- `maxTurns` — cap agentic turns on a bounded task, to stop a runaway +- `effort` — `low` for a single lookup, `high` or above for multi-file analysis; 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 +- `skills` — skill names preloaded at agent startup; unrelated to the `source_keys` metadata field +- `color` — the UI tile colour (`red`, `blue`, `green`, `yellow`, `purple`, `orange`, `pink`, + `cyan`) +- `background` — `true` forces background execution +- `initialPrompt` — auto-submitted as the first turn when the agent activates as the main session + thread; set it only for a main-thread agent, never for a subagent + +`hooks`, `mcpServers` and `permissionMode` are honoured at these two scopes and nowhere else — a +plugin agent carrying them is ignored silently. + +A subdirectory under `agents/` does not affect the agent's name at these scopes; it does at plugin +scope, which is one reason `references/deployment-modes.md` recommends keeping agents flat. + +## Copilot file + +Two Copilot formats exist, with different paths and different field sets. Pick one: + +**CLI format** — what the scaffold writes. + +- Path: `.github/agents/.agent.md` (project) or `~/.copilot/agents/.agent.md` (user) +- The `.agent.md` extension is mandatory: Copilot CLI does not pick up a plain `.md` file in + `agents/`, and fails silently rather than reporting it +- Fields: `name` (required, must equal the filename stem), `description` (required), `tools` + (optional) +- `tools` uses Copilot aliases, not Claude tool names: `execute` (shell), `read`, `edit`, + `search`, `agent`, `web`; MCP tools as `server-name/tool-name` or `server-name/*` + +**Cloud/IDE format** — for Copilot Chat in VS Code or on GitHub.com. + +- Path: `.github/copilot/agents/.md` — a plain `.md`, in a different directory +- Adds `target` (`vscode`, `github-copilot`, or omit for both), `user-invocable`, + `disable-model-invocation` and `mcp-servers` (processed by the cloud runtime, ignored in VS + Code). These four are inert in the CLI format — do not write them there +- This is the only format that can express the invocation axis in frontmatter; see the Invocation + axis section of `references/contract.md` + +Both formats truncate a body past **30,000 characters** silently. + +Copilot has no `permissionMode`, `maxTurns`, `isolation`, `memory`, `effort`, `hooks` or +`mcpServers`. Never let those cross over from the Claude Code file. + +## Before invoking `agent-audit` + +Both files: + +- [ ] `name` present and kebab-case; `description` written to `references/contract.md` +- [ ] System prompt body present, non-empty and equivalent across the pair +- [ ] Body covers all four required elements: inputs expected, process steps, output format, + **error handling** — what the agent does on malformed, missing or contradictory input +- [ ] No `FILL IN:` placeholder and no `` template comment left + +Copilot file only: + +- [ ] Extension is `.agent.md` (CLI format), and `name` matches the filename stem +- [ ] No Claude Code-only field present +- [ ] Body under 30,000 characters + +Then return to the flow reference you came from. diff --git a/plugins/kyberforge/.apm/skills/agent-author/references/sources.md b/plugins/kyberforge/.apm/skills/agent-author/references/sources.md index 787efdb..923544f 100644 --- a/plugins/kyberforge/.apm/skills/agent-author/references/sources.md +++ b/plugins/kyberforge/.apm/skills/agent-author/references/sources.md @@ -19,7 +19,7 @@ source_keys: - **URL:** context7:/websites/code_claude - **Research doc:** plugins/kyberforge/docs/research/docs/claude-code-plugins/sources.md - **Description:** Official Claude Code documentation site indexed by Context7 — plugin manifest schema, subagent definition types, marketplace JSON format, agent markdown file format -- **Contributing files:** SKILL.md, references/deployment-modes.md +- **Contributing files:** SKILL.md, references/create.md, references/deployment-modes.md, references/plugin-scope.md - **Status:** `extracted` ## claude-code-plugins-docs @@ -27,7 +27,7 @@ source_keys: - **URL:** https://code.claude.com/docs/en/plugins - **Research doc:** plugins/kyberforge/docs/research/docs/claude-code-plugins/sources.md - **Description:** Official Claude Code plugin authoring guide — plugin structure, manifest fields, loading methods, skill namespacing, agent activation, marketplace submission -- **Contributing files:** SKILL.md, references/deployment-modes.md +- **Contributing files:** SKILL.md, references/deployment-modes.md, references/plugin-scope.md - **Status:** `extracted` ## claude-code-subagents-docs @@ -35,7 +35,7 @@ source_keys: - **URL:** https://code.claude.com/docs/en/sub-agents - **Research doc:** plugins/kyberforge/docs/research/docs/claude-code-plugins/sources.md - **Description:** Official Claude Code subagent reference — definition format, all frontmatter fields, scope priority, built-in agents, CLI flags, environment variables, known limitations -- **Contributing files:** SKILL.md, references/deployment-modes.md +- **Contributing files:** SKILL.md, references/create.md, references/improve.md, references/contract.md, references/deployment-modes.md, references/project-user-scope.md - **Status:** `extracted` ## context7-github-en-copilot @@ -43,7 +43,7 @@ source_keys: - **URL:** context7:/websites/github_en_copilot - **Research doc:** plugins/kyberforge/docs/research/docs/github-copilot-plugins/sources.md - **Description:** Official GitHub Copilot documentation indexed by Context7; covers CLI plugins, custom agents, SDK, and marketplace -- **Contributing files:** SKILL.md, references/deployment-modes.md +- **Contributing files:** references/deployment-modes.md, references/project-user-scope.md - **Status:** `extracted` ## github-custom-agents-configuration @@ -51,7 +51,7 @@ source_keys: - **URL:** https://docs.github.com/en/copilot/reference/custom-agents-configuration - **Research doc:** plugins/kyberforge/docs/research/docs/github-copilot-plugins/sources.md - **Description:** Reference for cloud and IDE custom agent definition format — frontmatter fields, tool aliases, MCP server config, secrets interpolation, scoping hierarchy -- **Contributing files:** SKILL.md, references/deployment-modes.md +- **Contributing files:** references/contract.md, references/deployment-modes.md, references/project-user-scope.md - **Status:** `extracted` ## github-cli-plugin-reference @@ -59,7 +59,7 @@ source_keys: - **URL:** https://docs.github.com/en/copilot/reference/copilot-cli-reference/cli-plugin-reference - **Research doc:** plugins/kyberforge/docs/research/docs/github-copilot-plugins/sources.md - **Description:** Full CLI plugin reference — plugin.json schema, marketplace.json schema, all CLI commands and flags, install specification formats, loading precedence, env vars, LSP config -- **Contributing files:** SKILL.md, references/deployment-modes.md +- **Contributing files:** references/deployment-modes.md, references/project-user-scope.md - **Status:** `extracted` ## github-plugins-creating @@ -67,7 +67,7 @@ source_keys: - **URL:** https://docs.github.com/en/copilot/how-tos/copilot-cli/customize-copilot/plugins-creating - **Research doc:** plugins/kyberforge/docs/research/docs/github-copilot-plugins/sources.md - **Description:** How-to for creating Copilot CLI plugins — plugin structure, agent and skill authoring, hooks format, MCP config, development lifecycle -- **Contributing files:** SKILL.md +- **Contributing files:** references/create.md - **Status:** `extracted` ## github-plugins-finding-installing diff --git a/plugins/kyberforge/.apm/skills/agent-author/scripts/new-agent.sh b/plugins/kyberforge/.apm/skills/agent-author/scripts/new-agent.sh index 4742610..bb1559c 100755 --- a/plugins/kyberforge/.apm/skills/agent-author/scripts/new-agent.sh +++ b/plugins/kyberforge/.apm/skills/agent-author/scripts/new-agent.sh @@ -157,9 +157,11 @@ find_package_root() { done } -# `read` consumes a single line, so kind and path are emitted on one -# space-separated line rather than two `echo`s — kind first (never contains -# spaces), path last (absorbs any spaces in the path safely). +# kind and path are emitted on one space-separated line rather than two +# `echo`s — kind first (never contains spaces), path last (absorbs any spaces +# in the path safely). `mapfile`/`readarray` would need bash 4.0+, which +# macOS's stock /bin/bash 3.2 is not; a here-string `read` splits the single +# line without it. Same form as skill-author's new-skill.sh, deliberately. WALK_RESULT="$(find_package_root "$ROOT")" read -r WALK_KIND WALK_ROOT <<< "$WALK_RESULT" @@ -283,6 +285,8 @@ else if [[ "$SCOPE" == "plugin" ]]; then 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 " Description: 250 chars target / 400 ceiling (ADR-0020). The body has no" >&2 + echo " word gate — delegate to a skill instead of restating what it does." >&2 echo " 2. Populate $SOURCES_DIR/sources.md with research sources, or delete it" >&2 echo " 3. Validate: $VALIDATE_HINT $APM_FILE" >&2 echo " It checks the frontmatter against the apm-agent-allowlist section of" >&2 @@ -290,6 +294,8 @@ else else 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 " Description: 250 chars target / 400 ceiling (ADR-0020). The body has no" >&2 + echo " word gate — delegate to a skill instead of restating what it does." >&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 diff --git a/plugins/kyberforge/.apm/skills/skill-audit/README.md b/plugins/kyberforge/.apm/skills/skill-audit/README.md index 27e5f38..d46ca78 100644 --- a/plugins/kyberforge/.apm/skills/skill-audit/README.md +++ b/plugins/kyberforge/.apm/skills/skill-audit/README.md @@ -1,13 +1,17 @@ # skill-audit -Audit a skill directory against the agentskills.io specification. Runs structural validation then a qualitative review across description quality, body discipline, patterns, formatting, file structure, scripts, and internal consistency, plus a provenance chain check. +Audit a skill directory against the agentskills.io specification and the house context-budget contract (ADR-0020). Runs structural validation then a qualitative review across description quality, body discipline, patterns, formatting, file structure, scripts, and internal consistency, plus a provenance chain check. ## What it does -1. Runs `scripts/validate.sh` and `scripts/validate-provenance.sh` for structural and provenance checks, plus `scripts/vale-wrap.sh` — a Vale prefilter that deterministically flags known-bad description openers, vague wording, padding phrases, and "There is/are" sentence openers +1. Runs `scripts/validate.sh` and `scripts/validate-provenance.sh` for structural and provenance checks, plus `scripts/vale-wrap.sh` — a Vale prefilter that deterministically flags non-imperative description openers, composition and architecture notes, vague wording, padding phrases, and "There is/are" sentence openers 2. Reads all files in the skill directory -3. Applies qualitative checks across seven dimensions -4. Outputs a compact findings report — findings only, grouped by dimension, each with Why and Fix — and a result block with handoff to /skill-improve +3. Applies qualitative checks across five dimension groups, loading one rubric from `references/` per group +4. Outputs a compact findings report — findings only, grouped by dimension, each with Why and Fix — and a result block with handoff to `skill-author` + +`validate.sh` enforces two independent length families that must not be conflated: the agentskills.io spec conformance ceilings (500 lines, 2,770 words, both counting the whole file) and the ADR-0020 context budget (250/400 description characters, 600/900 body-only words). + +Alongside those it runs four shape checks that are not length measurements at all. Two are FAILs: every routing target named in the description — in the compressed `Not -> ` arrow **and** in the prose form — must resolve to a real skill or agent, and every `references/.md` the body names must exist on disk. Three are SUGGESTIONs: a missing boundary clause, a Gotchas section over five entries, and a Gotchas section over 25% of the body. The resolution universe for boundary targets is derived by walking up from the audited `SKILL.md` — the authoring root above it, its own apm package, and that package's declared `apm.yml` dependencies — so a fresh clone and a machine that has run `apm install` return the same verdict. When no universe can be determined the check prints `INFO ... DID NOT RUN` and does not silently pass. ## Usage @@ -22,16 +26,21 @@ Provide the path to the skill directory to audit when invoking. | File | Purpose | |------|---------| | `SKILL.md` | Skill instructions for agents | -| `scripts/validate.sh` | Structural validator — checks name format, name matches directory, description length, line count, placeholder detection, script executable bit, and interactive-prompt detection | +| `scripts/validate.sh` | Structural validator — checks name format, name matches directory, description presence and length, body-only word count, line and whole-file word ceilings, boundary-clause presence, boundary-target resolution, `references/` pointer existence, Gotchas entry count and body share, placeholder detection, script executable bit, and interactive-prompt detection | | `scripts/validate-provenance.sh` | Provenance validator — checks sources.md completeness, source_keys/slug consistency, Contributing files existence, bidirectional linkage, Research doc: fields, and upstream research doc alignment | | `scripts/vale-wrap.sh` | Vale prefilter wrapper — runs the bundled `Kyberforge` Vale styles against SKILL.md and reports alerts as deterministic FAILs ahead of Step 3's qualitative review | | `assets/vale/.vale.ini` | Vale configuration — points Vale at the bundled `Kyberforge` style path, self-located relative to `vale-wrap.sh` | -| `assets/vale/styles/Kyberforge/DescriptionOpener.yml` | Vale rule — flags literal "This skill..."/"This agent..." description openers | +| `assets/vale/styles/Kyberforge/CompositionNote.yml` | Vale rule — flags composition and architecture notes in a description (e.g. "cross-cutting", "entry point", "rather than duplicating") | +| `assets/vale/styles/Kyberforge/DescriptionOpener.yml` | Vale rule — flags non-imperative "This..." description openers | | `assets/vale/styles/Kyberforge/PaddingPhrase.yml` | Vale rule — flags generic "see references/" padding phrasing in conditional references | | `assets/vale/styles/Kyberforge/SentenceOpenerThereIs.yml` | Vale rule — flags body sentences starting with "There is"/"There are" | | `assets/vale/styles/Kyberforge/VagueWording.yml` | Vale rule — flags known filler wording (e.g. "helps with", "utilize") | -| `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/description-quality.md` | Rubric for the description dimension — three-part shape, the 250/400-character budget, the hand-invoked (`disable-model-invocation`) contract, and the internal-mechanics FAIL | +| `references/body-discipline.md` | Rubric for the body-discipline dimension — the core test, the 600/900 body-only budget against the 2,770-word whole-file backstop, the mandatory-dispatch rule, and the Gotchas constraints | +| `references/patterns.md` | Rubric for the patterns dimension — which instruction construct fits which job, and how each is correctly formed | +| `references/file-structure.md` | Rubric for the file-structure and internal-consistency dimensions — permitted directories, cross-plugin path rules and their two structural exemptions, README drift | +| `references/formatting-and-scripts.md` | Rubric for the formatting and scripts dimensions — heading and fencing conventions, and the agentic-use criteria for bundled scripts | +| `references/validation-scripts.md` | Step 1 troubleshooting — the manual structural fallback when `validate.sh` cannot run, and the script exit codes that are easy to misread (loaded only on a script failure) | | `references/sources.md` | Provenance record — agentskills.io sources that informed this skill and which files each contributed to | | `tests/validate.bats` | (source-only) Bats test suite for validate.sh | | `tests/validate-provenance.bats` | (source-only) Bats test suite for validate-provenance.sh | diff --git a/plugins/kyberforge/.apm/skills/skill-audit/SKILL.md b/plugins/kyberforge/.apm/skills/skill-audit/SKILL.md index 6db7d40..5cc3a82 100644 --- a/plugins/kyberforge/.apm/skills/skill-audit/SKILL.md +++ b/plugins/kyberforge/.apm/skills/skill-audit/SKILL.md @@ -1,19 +1,10 @@ --- name: skill-audit description: > - Use when the user wants to review a skill they wrote, says "audit this skill", - "check if my skill follows best practices", "review my SKILL.md", or wants to - know if a skill is ready to ship — even if they don't use the word "audit". - Also invoke proactively after directly hand-editing a skill's files outside - skill-author — an unaudited hand-edit is the same risk as unreviewed code. - Audits a skill directory against the agentskills.io specification — structural - checks plus qualitative review of description quality, body discipline, patterns, - formatting, file structure, scripts, and internal consistency, plus a provenance - chain check. Produces a compact findings report - (findings only, no PASS noise) with Why and Fix per finding, suitable for agent - handoff to /skill-improve or human auditability. Do not use to fix application - code bugs or perform general code review unrelated to skill quality. - Do not use when the user wants improvements applied — use /skill-improve instead. + Use when the user wants a skill directory audited against the agentskills.io + spec — "audit this skill", "review my SKILL.md", "is this ready to ship" — or + after hand-editing a skill outside skill-author. Not applying fixes -> + skill-author. allowed-tools: Bash Read metadata: category: factory @@ -27,9 +18,14 @@ metadata: ## Gotchas -- Do not output PASS/FAIL per check while auditing — gather findings internally and surface them only in the Step 4 report. Narrating each check as you go is the default failure mode here. +- Do not narrate PASS/FAIL per check while auditing. Gather findings internally and surface them only in the Step 4 report. Narrating each check as you go is the default failure mode here. +- A skill carrying `disable-model-invocation: true` is hand-invoked — its description is never routed against, so the trigger, capability and boundary rules do not apply. Audit it as one plain human-facing sentence instead. +- `validate.sh` reports two independent length families: the 500-line / 2,770-word pair counts the whole file for spec conformance, while the 250/400-character and 600/900-word pair is the house context budget and its word half counts the **body only**. A skill can sit inside one and fail the other — report them separately. +- Vale reporting `0 files` scanned means NOT RUN, not clean. Fall back to full Step 3 judgment for every dimension it would have covered. -## Step 1 — Structural validation +## Step 1 — Deterministic checks + +Resolve all three paths against this skill's own directory so they work from a repo checkout and an installed plugin cache alike. Run exactly: ```bash bash scripts/validate.sh @@ -37,98 +33,49 @@ bash scripts/validate-provenance.sh scripts/vale-wrap.sh /SKILL.md ``` -Note any structural FAILs — they will appear in the report as a `### Structure` dimension. If the script cannot execute (python3 unavailable, Bash denied, or permission error), perform structural checks manually: name format, name matches directory, description length ≤1024 chars, SKILL.md ≤500 lines and ≤2770 words (the word count is a proxy for the ~5,000-token ceiling, and blocks a commit exactly like the line count does), no unfilled `FILL IN:` placeholders, scripts executable and free of interactive prompts. +`validate.sh` findings become the `### Structure` dimension — its FAILs and its SUGGESTIONs both. -Note any Provenance FAILs and INFO findings from `validate-provenance.sh` — they surface in the report as a `### Provenance` dimension (separate from `### Structure`). The script embeds full FAIL/INFO format with Why and Fix per finding; surface them verbatim. +If any of the three cannot run, or exits non-zero for a reason other than findings, read `references/validation-scripts.md` — it carries the manual fallback and the misleading exit codes. Ordinary content FAILs are the expected outcome here and need no fallback. -`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". It applies that config's `Kyberforge` style — a deterministic prefilter for a subset of the Description/Patterns/Body dimensions below, not a replacement for Step 3. Every Vale alert is a `FAIL` — all rules are graded `error` — so report each one citing its rule ID (e.g. `Kyberforge.DescriptionOpener`). Skip and fall back to Step 3 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 3 judgment for the dimensions it would have covered. +`validate-provenance.sh` prints nothing on success. Its FAIL and INFO findings become a separate `### Provenance` dimension, and it emits Why and Fix itself — surface those verbatim. -## Step 2 — Read all skill files +`vale-wrap.sh` applies the bundled `Kyberforge` style as a prefilter. Pass no `--config`; the wrapper locates its own. Every rule is graded `error`, so every alert is a FAIL. Report each one citing its rule ID, filed under the dimension it belongs to, and do not re-derive it by judgment: -Read every file in the skill directory: `SKILL.md`, `README.md` (if present), all files in `scripts/`, `references/`, `assets/`, and `tests/`. Skip binary files only. Do not skip text files — internal consistency checks require the full picture. +| Rule | Dimension | +|---|---| +| `Kyberforge.DescriptionOpener`, `Kyberforge.CompositionNote`, `Kyberforge.VagueWording` | description | +| `Kyberforge.SentenceOpenerThereIs` | body-discipline | +| `Kyberforge.PaddingPhrase` | patterns | + +## Step 2 — Read the whole skill + +Read `SKILL.md`, `README.md`, and every text file under `scripts/`, `references/`, `assets/` and `tests/`. Skip binaries only — internal-consistency findings need the full picture. ## Step 3 — Qualitative audit -Work through each dimension internally. Collect findings only; report them in Step 4. Cite file and line number for every finding. +Load a dimension's rubric before judging that dimension. Each is self-contained, and each is grounded in the agentskills.io specification plus the house context-budget contract (ADR-0020). -### Description +| Dimension | Read | +|---|---| +| description | `references/description-quality.md` | +| body-discipline | `references/body-discipline.md` | +| patterns | `references/patterns.md` | +| file-structure, internal-consistency | `references/file-structure.md` | +| formatting, scripts | `references/formatting-and-scripts.md` | -Vale's `Kyberforge.DescriptionOpener` ("This skill..." openers) and `Kyberforge.VagueWording` (filler like "helps with", "utilize") alerts from Step 1 — both FAILs — cover imperative phrasing and known vague-wording filler directly; report them as findings without re-deriving by judgment. The rest is still a judgment call: - -- **Action-verb opening**: does the description start with a verb ("Audits...", "Reviews...", "Validates...")? Vale's `Kyberforge.DescriptionOpener` alert only catches the literal "This skill..." pattern — confirming an arbitrary opening word is genuinely a strong verb still requires judgment. -- **Specificity beyond the filler blocklist**: are capabilities stated precisely ("parses OpenAPI specs") or genuinely vaguely ("handles files")? -- **Indirect triggers**: does it cover cases where the user doesn't name the domain directly? -- **Near-miss exclusions**: are "Do not use when..." clauses present if a near-miss skill could steal activations? -- **Length**: under 1024 characters? - -If a description finding is borderline or the distinction between PASS and FAIL is unclear, read `references/description-quality.md`. - -### Body discipline - -For each sentence in the body, apply: *"Would the agent get this wrong without this sentence?"* Flag any that answer "no" as padding. - -- **Defaults not menus**: every decision point gives one default + one escape hatch, not a list of options -- **Why rationale**: include/exclude rules explain why, not just what -- **Control calibration**: prescriptive for fragile or critical sequences (e.g. a script invocation where flag order or exact arguments must not change); flexible where multiple approaches are valid - -Vale's `Kyberforge.SentenceOpenerThereIs` alert from Step 1 (FAIL — sentences starting with "There is"/"There are") covers pattern-matchable body-wide filler directly; report it as a finding without re-deriving by judgment. - -If uncertain whether a sentence is padding or whether a control decision is correctly calibrated, read `references/body-discipline.md`. - -### Patterns - -Check each pattern is appropriate and correctly formed: - -- **Gotchas**: placed near the top; each entry is a specific fact that defies a reasonable assumption — not a general tip -- **Prescriptive sequence**: inner code fences escaped as `\`\`\`` when nested inside a markdown block -- **Checklists**: used for multi-step workflows, not single steps -- **Conditional references**: specific trigger stated ("If X, read `references/file.md`") — not a generic "see references/". Vale's `Kyberforge.PaddingPhrase` alert from Step 1 flags the generic phrasing directly; other malformed conditional-reference forms still require judgment. -- **Output templates**: present when the agent must produce a specific format; absent otherwise - -### File structure - -- Permitted directories: `scripts/`, `references/`, `assets/`, `tests/`; flag any other unlisted directory as FAIL — the spec allows additional dirs but this skill permits only these four to keep skills focused -- `scripts/` contains only executable code agents can run; test files (`.bats`, `*_test.*`, `test_*.sh`) in `scripts/` are a FAIL — they belong in `tests/` -- No non-spec files at the skill root (e.g. META.md, extra config files outside permitted directories) -- Optional directories contain real content — not just unfilled placeholder READMEs -- `README.md` present and accurately describes the skill and its files -- No cross-plugin path references in SKILL.md, scripts/, references/, or assets/ — paths using `../`, `../../`, or absolute repo paths (e.g. `plugins//skills//`, or its APM-native equivalent `.apm/skills//`) break when the plugin is installed to a cache; flag any found -- `references/sources.md` is exempt from the cross-plugin path check — `Research doc:` fields are development-only provenance pointers, not runtime references; they intentionally reference paths outside the skill directory and are expected to be non-resolvable after plugin install; `validate-provenance.sh` handles this gracefully by silently skipping upstream checks when those paths don't resolve -- `tests/` is exempt from the cross-plugin path check — test files are dev-only and may reference repo-level test infrastructure (e.g. a shared `tests/test_helper/`). This dependency must be declared in `tests/README.md`; flag if tests exist but `tests/README.md` is absent or does not document the dependency - -### Formatting - -- Heading levels consistent: H2 for main sections, H3 for subsections -- Code blocks fenced with a language tag where applicable (`bash`, `markdown`, `python`) -- Consistent whitespace: blank line between sections, consistent list indentation -- No broken relative paths in file references - -### Scripts - -- No interactive TTY prompts (`read`, `input()`, `readline`) -- `--help` exposed with concise usage -- Data to stdout, diagnostics to stderr -- Idempotent ("create if not exists") -- Meaningful exit codes documented in `--help` -- `--dry-run` present for destructive operations - -### Internal consistency - -- SKILL.md steps match what scripts actually do -- `README.md` file table lists every file that exists — no missing entries, no stale entries -- Placeholder READMEs in `scripts/`, `references/`, `assets/` consistent with what SKILL.md says about each directory +Cite file and line number for every finding. ## Step 4 — Report -Open with a coverage line listing every dimension checked: +Open with a coverage line naming every dimension checked: ```text Checked: structure · description · body-discipline · patterns · file-structure · formatting · scripts · internal-consistency · provenance ``` -Then output only dimensions that have findings, grouped under H3 headings, FAILs before SUGGESTIONs within each dimension. Omit clean dimensions entirely — their absence confirms they passed. +Then output only the dimensions that have findings, grouped under H3 headings, FAILs before SUGGESTIONs within each. Omit clean dimensions — their absence is what confirms they passed. -For each finding: +Each finding: ```text FAIL/SUGGESTION — file:line @@ -136,18 +83,4 @@ FAIL/SUGGESTION — file:line Fix: ``` -Close with a result block: - -```text -## Result - -PASS -PASS (N suggestions) -PASS · P info -PASS (N suggestions) · P info -FAIL (N fails · M suggestions) -FAIL (N fails · M suggestions) · P info -Run /skill-improve to address findings. -``` - -INFO findings are observational — do not affect PASS/FAIL. Omit `· P info` when there are no INFO findings. Omit the `/skill-improve` line when there are no findings at all. Do not apply fixes — report and propose only. +Close with a `## Result` block holding one line: `PASS`, `PASS (N suggestions)`, or `FAIL (N fails · M suggestions)`, each optionally followed by ` · P info`. INFO findings are observational and never change PASS/FAIL; omit `· P info` when there are none. Add a second line, `Run skill-author to address findings.`, whenever there is at least one finding. Do not apply fixes — report and propose only. diff --git a/plugins/kyberforge/.apm/skills/skill-audit/assets/vale/styles/Kyberforge/CompositionNote.yml b/plugins/kyberforge/.apm/skills/skill-audit/assets/vale/styles/Kyberforge/CompositionNote.yml new file mode 100644 index 0000000..90ea015 --- /dev/null +++ b/plugins/kyberforge/.apm/skills/skill-audit/assets/vale/styles/Kyberforge/CompositionNote.yml @@ -0,0 +1,13 @@ +extends: existence +message: "Composition or architecture note in a description: '%s' — a description carries a trigger, one capability clause and a boundary clause only; move this to README.md" +level: error +scope: text.frontmatter.description +ignorecase: true +tokens: + - cross-cutting + - shared (skill|agent) + - human-facing + - entry[- ]point + - composes + - rather than duplicating + - replaces the (old|former|previous) diff --git a/plugins/kyberforge/.apm/skills/skill-audit/assets/vale/styles/Kyberforge/DescriptionOpener.yml b/plugins/kyberforge/.apm/skills/skill-audit/assets/vale/styles/Kyberforge/DescriptionOpener.yml index d978d73..1f41236 100644 --- a/plugins/kyberforge/.apm/skills/skill-audit/assets/vale/styles/Kyberforge/DescriptionOpener.yml +++ b/plugins/kyberforge/.apm/skills/skill-audit/assets/vale/styles/Kyberforge/DescriptionOpener.yml @@ -4,4 +4,4 @@ level: error scope: text.frontmatter.description ignorecase: true raw: - - '^This (skill|agent)\b' + - '^This\b' diff --git a/plugins/kyberforge/.apm/skills/skill-audit/references/body-discipline.md b/plugins/kyberforge/.apm/skills/skill-audit/references/body-discipline.md index fd73c4c..4f4af51 100644 --- a/plugins/kyberforge/.apm/skills/skill-audit/references/body-discipline.md +++ b/plugins/kyberforge/.apm/skills/skill-audit/references/body-discipline.md @@ -6,31 +6,123 @@ source_keys: # Body Discipline Reference -Source: agentskills.io — skill-authoring +Upstream source: agentskills.io — skill-authoring, best-practices. +House contract: ADR-0020, the context budget. ## The core test For every sentence in the body, ask: **"Would the agent get this wrong without this instruction?"** -If no — cut it. The agent already knows it from general training. Adding it wastes tokens and dilutes the signal of what matters. +If no — cut it. The agent already knows it from general training. Adding it wastes tokens and +dilutes the signal of what matters. -## What belongs in the body +## What the body is for + +The body carries the **decision procedure only**: ordered steps, decision branches, gates, and +which reference to load when. Include content the agent lacks: + - Project-specific conventions and domain procedures it cannot infer - Non-obvious edge cases and environment-specific gotchas -- The specific tools or sequences to use (not the full range of options) +- The specific tools or sequences to use — not the full range of options - One default per decision point with one escape hatch -Do not include: +Move to `references/`, behind an explicit "If X, read `references/.md`" trigger — the literal +conditional form, never a generic pointer. Write the real filename in the skill under audit; the +angle brackets are a placeholder here, and a literal `references/file.md` in a body is an ERROR +from the ADR-0020 gate because no such file exists on disk. Move: + +- Lookup tables and spec restatements +- Output schemas, templates and example blocks +- Rationale and justification prose +- Anything only one branch of the procedure ever reaches + +Do not include at all: + - Concepts the agent already knows (what JSON is, how HTTP works, what a CSV is) -- Exhaustive option lists — pick a default; the agent doesn't benefit from choosing +- Exhaustive option lists — pick a default; the agent does not benefit from choosing - Steps the agent handles independently — over-specifying leads to unproductive paths -- Restatements of the description — it's already in context +- Restatements of the description, which is already in context + +## Two length families, measured differently + +Do not conflate these, and do not report them as one finding. + +| Gate | SUGGESTION | FAIL | Counts | +|---|---|---|---| +| Body budget (house, ADR-0020) | 600 words | 900 words | the **body only** — everything after the frontmatter's closing `---` | +| Spec conformance (agentskills.io) | — | 2,770 words / 500 lines | the **whole file**, frontmatter included | + +The 2,770-word ceiling is a token-conformance backstop calibrated to the densest prose in the +corpus; it says nothing about quality and a file can sit a thousand words inside it while failing +the body budget. The 900-word ceiling is the quality gate: a body is loaded into the caller's live +context and competes with the conversation already there. `validate.sh` reports both. Cite whichever +one actually fired. + +A word count cannot detect the defect it stands in for. Treat both numbers as backstops to the +dispatch rule and the Gotchas constraint below, never as a substitute for them. + +## Dispatch is mandatory at two or more mutually exclusive flows + +If a skill handles two or more flows that a single invocation cannot both take — separate +subcommands, separate input types, separate lifecycle stages — the body carries a **dispatch +table** plus the gates common to every branch, and each flow lives in its own self-contained +`references/` file. Inlining all of them is a FAIL regardless of word count, because every +invocation then pays for every branch it did not take. + +The reference shape in this repo is `apm-workflow`: a **421-word body** dispatching to roughly +3,000 words of references across five mutually exclusive invocations. Its whole-file count is 554 +words — cite 421 when calibrating a body, or the conflation this section warns against reappears +in the finding itself. + +## Gotchas sections + +The highest-value construct in a body, and the easiest to fill with noise. A Gotcha must state a +fact that **contradicts a reasonable default** — something the agent gets wrong precisely by acting +sensibly. + +```markdown +## Gotchas +- The `users` table uses soft deletes. Always include `WHERE deleted_at IS NULL`. +- User ID is `user_id` in the database, `uid` in auth, `accountId` in billing. Same value. +``` + +Constraints: + +- **More than five entries is a SUGGESTION** — five is the guideline, not a ceiling. Past five, the + section is usually a summary of the body rather than a set of traps, and the agent stops reading + it as a warning. It stays advisory because whether a given gotcha earns its place is judgment; + `validate.sh` emits it through `suggest()` and the run still exits 0. +- **A Gotcha that paraphrases a step in the body below it is a FAIL.** It has no independent + content, and it teaches the agent that Gotchas can be skimmed because the real instruction is + coming. This one is the auditor's call — no script detects it. +- **A Gotchas section exceeding 25% of the body is a SUGGESTION** — the body has been inverted into + a preamble. Same tier and same reasoning as the entry count, and independent of it: either can + fire without the other. +- Place the section near the top. A gotcha read after the mistake is worthless, which is also why + Gotchas is the one construct exempt from moving to `references/`. + +Worked negative example — `git-commits` carries twelve entries, of which four restate content +that already appears below or in the description: + +| Gotcha | Restates | +|---|---| +| `:31` "Communicates SemVer impact" | the description | +| `:32` "Confirmation gates are mandatory for destructive operations" | step 9 at `:52` | +| `:33` "Never skip hooks with `--no-verify`" | step 9 at `:52` | +| `:36` "Never commit secrets" | step 2 at `:45` | + +All four are FAILs under the paraphrase rule. The entry count and the section's share of the body +(387 of 1,102 words, 35%) are two further SUGGESTIONs on top — the script reports both, and neither +fails the run on its own. What makes this worth auditing directly is that the four paraphrase FAILs +pass every word gate there is; only reading the construct finds them. ## Calibrating control -**Be prescriptive** when operations are fragile, consistency matters, or a specific sequence must be followed: +**Be prescriptive** when operations are fragile, consistency matters, or a specific sequence must be +followed: + ```markdown Run exactly: \`\`\`bash @@ -39,11 +131,13 @@ python scripts/migrate.py --verify --backup Do not modify the command or add additional flags. ``` -**Give freedom** when multiple approaches are valid. Explaining *why* outperforms rigid directives — agents make better decisions when they understand the purpose. +**Give freedom** when multiple approaches are valid. Explaining *why* outperforms rigid directives — +agents make better decisions when they understand the purpose. ## Defaults not menus Never present a list of equivalent options — pick one and mention the alternative briefly: + ```markdown # Too many options Use pypdf, pdfplumber, PyMuPDF, or pdf2image... @@ -52,37 +146,23 @@ Use pypdf, pdfplumber, PyMuPDF, or pdf2image... Use pdfplumber for text extraction. For scanned PDFs requiring OCR, use pdf2image instead. ``` -## Gotchas sections - -Highest value content — environment-specific facts that defy reasonable assumptions. Place near the top of the body so the agent reads them before encountering the situation. - -```markdown -## Gotchas -- The `users` table uses soft deletes. Always include `WHERE deleted_at IS NULL`. -- User ID is `user_id` in the database, `uid` in auth, `accountId` in billing. Same value. -``` - -Each entry must be a specific, surprising fact — not a general tip or reminder. - -## Progressive disclosure - -Keep `SKILL.md` under 500 lines. When more content is needed, move it to `references/` and load conditionally: - -```markdown -If the API returns a non-200 status, read `references/api-errors.md`. -``` - -"If X, read Y" is more useful than "see references/ for details." The agent loads on demand rather than up front. - ## Auditing guidance Flag as FAIL if: -- A sentence answers "no" to the core test (would agent get this wrong without it?) — it is padding -- Decision points present a menu of options with no default -- Instructions repeat content already in the description -- Prescriptive sequences are used where flexibility is fine, or vice versa + +- A sentence answers "no" to the core test — it is padding +- The body exceeds 900 words counted body-only (`validate.sh` reports it) +- Two or more mutually exclusive flows are inlined instead of dispatched +- A Gotcha paraphrases a step in the body below it +- A decision point presents a menu of options with no default +- An instruction repeats content already in the description +- A prescriptive sequence is used where flexibility is fine, or the reverse Flag as SUGGESTION if: -- A rationale is missing from an include/exclude rule (present but unexplained) + +- The body exceeds 600 words counted body-only but stays at or under 900 +- The Gotchas section carries more than five entries +- The Gotchas section exceeds 25% of the body +- A rationale is missing from an include/exclude rule — present but unexplained - Gotchas are correct but placed late in the body rather than near the top -- A conditional reference trigger is vague ("see references/") rather than specific ("If X, read Y") +- Content that only one branch reaches is inlined where a `references/` file would serve diff --git a/plugins/kyberforge/.apm/skills/skill-audit/references/description-quality.md b/plugins/kyberforge/.apm/skills/skill-audit/references/description-quality.md index a458919..411d178 100644 --- a/plugins/kyberforge/.apm/skills/skill-audit/references/description-quality.md +++ b/plugins/kyberforge/.apm/skills/skill-audit/references/description-quality.md @@ -6,49 +6,112 @@ source_keys: # Description Quality Reference -Source: agentskills.io — optimizing-descriptions +Upstream source: agentskills.io — optimizing-descriptions, specification. +House contract: ADR-0020, the context budget. The house contract is narrower than the spec +rather than a reinterpretation of it: where both speak, both must be satisfied. -## How triggering works +## Why the description is the expensive part -At startup, agents load only the `name` and `description` of each skill. When a user's task matches a description, the agent reads the full `SKILL.md` into context. **The description carries the entire triggering burden** — the body is never seen until after triggering. +At startup an agent loads only the `name` and `description` of every installed skill. The body is +never seen until the skill triggers. The description therefore carries the entire triggering +burden **and** is paid for in every session, whether the skill fires or not. -Agents typically consult skills only for tasks requiring knowledge beyond their defaults. Specialized knowledge — unfamiliar APIs, domain-specific workflows, uncommon formats — is where description wording makes the difference. +A second cost is less obvious and is a correctness hazard rather than a token cost: a description +that summarises the workflow is a shortcut the agent takes *instead of* reading the body. A +measured failure upstream — a description saying "code review between tasks" — produced one review +where the body's flowchart specified two. -## What a good description does +## Step 0 — establish which contract applies -- **Imperative phrasing** — "Use when..." not "This skill does...". The agent is deciding whether to act. -- **User intent, not mechanics** — describe what the user is trying to achieve, not how the skill works internally. -- **Err toward being pushy** — explicitly name contexts where the skill applies, including cases where the user doesn't name the domain: "even if they don't mention X explicitly." -- **Specificity over vagueness** — "parses and validates OpenAPI specs" beats "helps with APIs." -- **Near-miss exclusions** — add "Do not use when..." only if a near-miss skill exists that could steal activations. Use strong near-misses (queries that share keywords but need something different), not weak ones ("write a fibonacci function"). -- **Hard limit: 1024 characters** — descriptions grow during revision; check length before finalising. +Read the frontmatter before judging a single word. + +- **`disable-model-invocation: true`** — the skill is hand-invoked. Its description is never + matched against user intent, so it is not a routing string. It carries **one plain human-facing + sentence** stating what the skill does. Audit it for that and nothing else. Reporting a missing + trigger clause, a missing boundary clause or absent indirect triggers on a hand-invoked skill is + a wrong finding, not a strict one. +- **No such flag** — the skill is model-invoked and the rest of this file applies. + +## The three-part shape + +A model-invoked description carries exactly three things: + +1. **Trigger clause.** When to invoke, phrased imperatively: `Use when ...`. Not `This skill ...` — + the agent is deciding whether to act, not reading a catalogue entry. +2. **At most one capability clause.** What it does, in one clause. Never an enumeration. +3. **Boundary clause.** Compressed form: `Not -> .` The target must resolve to + a real skill directory or agent file in the authoring source; `validate.sh` checks that + deterministically and a dangling target already surfaces as a Structure FAIL. + +Everything else belongs in the body or in `README.md`. + +## Indirect triggers — conditional, never blanket + +Add "even if the user doesn't say X" **only where the user's natural phrasing genuinely omits the +domain word.** True for the `gitea-*` family: people say "create an issue", not "create a Gitea +issue". False for `git-commits`: nobody asks for a commit without saying commit. A blanket +indirect-trigger clause on a skill whose domain word is unavoidable is padding charged to every +session. + +## Near-miss exclusions + +Add a boundary clause only where a sibling skill could plausibly steal the activation. Use strong +near-misses — queries that share keywords but need something different — not weak ones ("write a +fibonacci function"). One boundary clause per genuine near-miss; a list of four is enumeration +wearing a boundary's clothes. ## Before / after ```yaml -# Weak -description: Process CSV files. - -# Strong +# FAIL — enumeration first, mechanics as the opener, a blanket indirect trigger, +# and 300+ characters of it preloaded into every session forever. description: > - Analyze CSV and tabular data files — compute summary statistics, - add derived columns, generate charts, and clean messy data. Use when - the user has a CSV, TSV, or Excel file and wants to explore, transform, - or visualize the data, even if they don't explicitly mention "CSV" or - "analysis." + Analyze CSV and tabular data files — compute summary statistics, add derived + columns, generate charts, and clean messy data. Use when the user has a CSV, + TSV, or Excel file and wants to explore, transform, or visualize the data, + even if they don't explicitly mention "CSV" or "analysis." + +# PASS — trigger, one capability clause, boundary. The four verbs the FAIL +# version enumerates are the body's job; the router cannot act on them. +description: > + Use when the user has a CSV, TSV, or Excel file and wants it explored, + transformed, or charted. Not schema design -> data-model. ``` -The strong version names capabilities precisely and broadens applicability beyond explicit keyword matches. +(`data-model` is illustrative. In a real description the target has to resolve.) ## Auditing guidance Flag as FAIL if: -- Phrasing is descriptive ("This skill...") not imperative ("Use when...") -- Capabilities are vague ("helps with APIs") — require precise verbs and nouns -- No indirect trigger coverage when indirect cases clearly exist -- No near-miss exclusions when a sibling skill could plausibly steal activations -- Length exceeds 1024 characters + +- **Over 400 characters.** Measured on the folded YAML value, not the raw source lines. + `validate.sh` reports the number; do not re-derive it, but do point the Fix at what to cut. +- **Internal mechanics appear in the description.** Any of: + - capability enumeration or a feature list; + - output-format detail ("Produces a compact findings report with Why and Fix per finding"); + - composition or architecture notes ("composes X rather than duplicating Y", "a cross-cutting + shared skill", "the human-facing entry point", "replaces the old flat invocation"); + - implementation detail ("self-validates via a bundled deterministic script"). + + None of it can change a routing decision and all of it is preloaded. + `Kyberforge.CompositionNote` catches the common phrasings deterministically; the rest is + judgment. This is the rule that deflates a description, so apply it before reaching for length. +- **The same trigger stated twice in two registers** — a verb list, then the same verbs re-quoted + as user phrasings, usually in the same order. One register, whichever routes better. +- **Descriptive rather than imperative phrasing** (`This skill ...`, `This is the ...`). + `Kyberforge.DescriptionOpener` catches any opener matching `^This`. +- **Vague capabilities** ("helps with APIs" where "parses and validates OpenAPI specs" was + available). `Kyberforge.VagueWording` catches the known filler; imprecision outside that list is + judgment. +- **A boundary clause naming a target that does not resolve** to a real skill directory or agent + file in the authoring source. `validate.sh` reports the unresolved name. +- **Trigger-list, boundary or indirect-trigger content on a hand-invoked skill** — see Step 0. +- **Over 1024 characters** — the agentskills.io specification ceiling, unchanged and independent + of the 400-character house ceiling above. Flag as SUGGESTION if: -- Indirect trigger coverage exists but could be more specific -- Near-miss exclusions are present but target weak near-misses only + +- **Over 250 characters** but at or under 400. This tier is what moves the corpus average; the FAIL + tier only stops outliers. Report it rather than treating a 399-character description as clean. +- A near-miss exclusion is present but targets a weak near-miss. +- An indirect trigger is present and warranted but could name the omitted phrasing more precisely. diff --git a/plugins/kyberforge/.apm/skills/skill-audit/references/file-structure.md b/plugins/kyberforge/.apm/skills/skill-audit/references/file-structure.md new file mode 100644 index 0000000..805ba14 --- /dev/null +++ b/plugins/kyberforge/.apm/skills/skill-audit/references/file-structure.md @@ -0,0 +1,78 @@ +--- +source_keys: + - agentskills-spec +--- + +# File Structure and Internal Consistency Reference + +Upstream source: agentskills.io — specification (optional directories, file references). + +Read this when judging the **file-structure** and **internal-consistency** dimensions. + +## Permitted directories + +Only four: `scripts/`, `references/`, `assets/`, `tests/`. The specification permits additional +directories; this house does not, because an unlisted directory is content no auditor and no host +knows to look at. Flag any other directory as a FAIL. + +- `scripts/` holds only executable code an agent can run. Test files (`.bats`, `*_test.*`, + `test_*.sh`) there are a FAIL — they belong in `tests/`. +- No non-spec files at the skill root: no `META.md`, no stray config outside the four directories. +- An optional directory that exists must hold real content, not an unfilled placeholder README. +- `README.md` is present and describes the skill and its files accurately. + +## Cross-plugin path references + +A plugin is copied to a cache on install, and a path that climbs out of the skill directory stops +resolving there. Flag any `../`, `../../`, or absolute repo path (`plugins//skills//` +and its APM-native equivalent `.apm/skills//`) appearing in `SKILL.md`, `scripts/`, +`references/` or `assets/`. + +**Referring to another skill's file.** There is one sanctioned spelling, and it is possessive: +`skill-audit's references/validation-scripts.md`. Write the skill by name and let the reader +resolve it — do not spell the repo path. The full path is the thing this section forbids, and +`references/validation-scripts.md` on its own is a hard ERROR from the ADR-0020 gate, which +requires an unqualified `references/` pointer to exist in the skill's OWN directory. The +possessive form is the only spelling both rules accept; the gate recognises it and skips the +on-disk check. Flag any other spelling of a cross-skill reference. + +Two directories are exempt, and the exemptions are structural rather than discretionary: + +- **`references/sources.md`.** Its `Research doc:` fields are development-time provenance pointers, + not runtime references. They are expected to be unresolvable after install, and + `validate-provenance.sh` handles that by skipping upstream checks silently when the path is + absent. Flagging them would make every correctly-provenanced skill fail. +- **`tests/`.** Test files are dev-only and may reference repo-level infrastructure such as a shared + `tests/test_helper/`. The exemption is conditional on the dependency being declared: if `tests/` + exists and `tests/README.md` is absent or does not document it, that is a FAIL. + +## Internal consistency + +The skill has to agree with itself. Three checks: + +- `SKILL.md`'s steps match what the scripts actually do — the arguments, the exit codes, and the + output shape it tells the agent to expect. +- `README.md`'s file table lists every file that exists, with no missing rows and no stale rows for + files since deleted. +- Placeholder READMEs inside `scripts/`, `references/` and `assets/` say the same thing about each + directory that `SKILL.md` does. + +A stale README row is the most common finding here and the easiest to miss from inside an +authoring pass, because the author knows what was intended and reads it into the gap. + +## Auditing guidance + +Flag as FAIL if: + +- A directory outside the four permitted ones exists +- Test files sit in `scripts/` +- A non-spec file sits at the skill root +- A cross-plugin or parent-relative path appears outside the two exempt locations +- `tests/` exists but `tests/README.md` is missing or does not document its repo-level dependency +- `README.md` is absent, or its file table has a missing or stale row +- `SKILL.md` describes a script invocation the script does not accept + +Flag as SUGGESTION if: + +- An optional directory exists but holds only a placeholder README +- `README.md` is accurate but describes a file's purpose more thinly than `SKILL.md` does diff --git a/plugins/kyberforge/.apm/skills/skill-audit/references/formatting-and-scripts.md b/plugins/kyberforge/.apm/skills/skill-audit/references/formatting-and-scripts.md new file mode 100644 index 0000000..9462bdf --- /dev/null +++ b/plugins/kyberforge/.apm/skills/skill-audit/references/formatting-and-scripts.md @@ -0,0 +1,63 @@ +--- +source_keys: + - agentskills-spec + - agentskills-using-scripts +--- + +# Formatting and Scripts Reference + +Upstream source: agentskills.io — specification (body content), using-scripts (designing scripts +for agentic use). + +Read this when judging the **formatting** and **scripts** dimensions. Both are checklists of static +criteria that never vary by skill, which is exactly why they live here rather than in the body. + +## Formatting + +- Heading levels are consistent: H2 for main sections, H3 for subsections. A body that jumps from + H2 to H4, or opens on H3, reads as a fragment of a larger document. +- Code blocks carry a language tag wherever one applies — `bash`, `markdown`, `python`, `yaml`, + `text`. An untagged block loses syntax highlighting and, more importantly, loses the signal of + what the agent is meant to do with it. +- Whitespace is consistent: a blank line between sections, one list-indentation style throughout. +- No broken relative paths in file references. Every `references/…`, `scripts/…` and `assets/…` + path named in the body resolves against the skill directory. + +## Scripts + +A script in a skill is run by an agent with no terminal and no human to answer it. The criteria +follow from that: + +- **No interactive TTY prompts** — no `read`, no `input()`, no `readline`. A script that blocks on + a prompt hangs the run with no diagnostic. `validate.sh` detects the common forms and reports + them under Structure; the judgment call is any prompt it cannot pattern-match. What counts is + where stdin comes from, not the word `read`: a `read` fed by a here-string, a here-doc, a pipe, + or a redirect from a file never touches a terminal and is not a finding. `validate.sh` excludes + those forms, so do not rewrite a working `read -r A B <<< "$line"` into parameter expansion to + satisfy this rule. +- **`--help` is exposed** and gives concise usage. +- **Data to stdout, diagnostics to stderr.** A caller piping the script has to be able to separate + the result from the commentary. +- **Idempotent** — "create if not exists" rather than "create", so a re-run after a partial failure + is safe. +- **Meaningful exit codes, documented in `--help`.** An agent branches on the exit code; an + undocumented one is a coin flip. +- **`--dry-run` present for destructive operations.** + +## Auditing guidance + +Flag as FAIL if: + +- A script prompts interactively, in any form +- A script exposes no `--help` +- A destructive script has no `--dry-run` +- Data and diagnostics share a stream, so the output cannot be piped +- A relative path named in the body does not resolve +- Heading levels are inconsistent enough to break the document's structure + +Flag as SUGGESTION if: + +- Exit codes are meaningful but undocumented in `--help` +- A code block is untagged where a language applies +- A script is idempotent in practice but does not say so, leaving a re-run's safety unclear +- List indentation or section spacing is inconsistent without breaking the render diff --git a/plugins/kyberforge/.apm/skills/skill-audit/references/patterns.md b/plugins/kyberforge/.apm/skills/skill-audit/references/patterns.md new file mode 100644 index 0000000..fb144ca --- /dev/null +++ b/plugins/kyberforge/.apm/skills/skill-audit/references/patterns.md @@ -0,0 +1,68 @@ +--- +source_keys: + - agentskills-spec + - agentskills-best-practices +--- + +# Patterns Reference + +Upstream source: agentskills.io — best-practices (instruction patterns), specification. + +Read this when judging the **patterns** dimension: whether each instruction construct a skill uses +is the right construct for the job and is correctly formed. Formation, not content — a Gotcha's +*content* is judged in `references/body-discipline.md`. + +## The constructs and when each is right + +| Construct | Right when | Wrong when | +|---|---|---| +| Gotchas | An environment fact contradicts a reasonable default | Used as a summary of the steps below | +| Prescriptive sequence | The operation is fragile and flag order or exact arguments must not change | Several approaches are equally valid | +| Checklist | A multi-step workflow the agent must complete in order | A single step dressed up as a list | +| Conditional reference | Detail is needed on one branch only | The reference is needed on every run and is loaded blind | +| Output template | The agent must emit a specific format a caller consumes | The output is prose nobody parses | + +## Formation rules + +**Gotchas** sit near the top of the body, before the steps that would otherwise walk into them. +Placement late in the body is a SUGGESTION, not a FAIL — the content is still correct, it is just +read after the mistake. + +**Prescriptive sequences** that quote a fenced block inside another markdown block must escape the +inner fence as `` \`\`\` ``. An unescaped inner fence terminates the outer block and the remaining +instructions render as prose. + +**Conditional references** state a specific trigger, naming a file that exists in the skill's own +`references/` directory: + +```text +If the API returns a non-200 status, read `references/api-errors.md`. +``` + +That block is fenced because the filename in it is illustrative — an unfenced `references/` pointer +in a `SKILL.md` body must resolve on disk or the ADR-0020 gate reports a hard ERROR. The generic +form — pointing at the directory and hoping — defeats +progressive disclosure, because the agent either loads everything or loads nothing. +`Kyberforge.PaddingPhrase` catches the common generic phrasing deterministically; other malformed +forms are judgment. + +**Output templates** belong in the body when the agent must emit them on every run, and in +`references/` when only one dispatch branch produces that output. A template inlined for a branch +most invocations never take is body-discipline padding. + +## Auditing guidance + +Flag as FAIL if: + +- A Gotcha entry is a general tip or a reminder rather than a fact that defies a reasonable + assumption +- An inner code fence is unescaped inside a markdown block, breaking the render +- A checklist wraps a single step +- A conditional reference gives no trigger — `Kyberforge.PaddingPhrase` reports the common form +- The agent must produce a specific format and no output template is given + +Flag as SUGGESTION if: + +- Gotchas are correctly formed but placed late in the body +- An output template is present but permissive where the consumer needs it exact +- A conditional reference names a trigger that is real but broader than the branch it guards diff --git a/plugins/kyberforge/.apm/skills/skill-audit/references/sources.md b/plugins/kyberforge/.apm/skills/skill-audit/references/sources.md index 3aa967f..fd89ca7 100644 --- a/plugins/kyberforge/.apm/skills/skill-audit/references/sources.md +++ b/plugins/kyberforge/.apm/skills/skill-audit/references/sources.md @@ -15,7 +15,7 @@ - **URL:** https://agentskills.io/specification.md - **Research doc:** plugins/kyberforge/docs/research/docs/agentskillsio/sources.md - **Description:** Complete SKILL.md format specification — frontmatter fields, constraints, body content, optional directories, progressive disclosure levels, file references, validation -- **Contributing files:** SKILL.md, references/body-discipline.md, references/description-quality.md +- **Contributing files:** SKILL.md, references/body-discipline.md, references/description-quality.md, references/patterns.md, references/file-structure.md, references/formatting-and-scripts.md, references/validation-scripts.md - **Status:** `extracted` ## agentskills-best-practices @@ -23,7 +23,7 @@ - **URL:** https://agentskills.io/skill-creation/best-practices.md - **Research doc:** plugins/kyberforge/docs/research/docs/agentskillsio/sources.md - **Description:** Best practices for skill creators — starting from real expertise, spending context wisely, calibrating control, instruction patterns (gotchas, templates, checklists, validation loops) -- **Contributing files:** SKILL.md, references/body-discipline.md +- **Contributing files:** SKILL.md, references/body-discipline.md, references/patterns.md - **Status:** `extracted` ## agentskills-optimizing-descriptions @@ -47,7 +47,7 @@ - **URL:** https://agentskills.io/skill-creation/using-scripts.md - **Research doc:** plugins/kyberforge/docs/research/docs/agentskillsio/sources.md - **Description:** Using scripts in skills — one-off commands, self-contained scripts with inline dependencies, designing scripts for agentic use (no interactive prompts, --help, structured output, idempotency) -- **Contributing files:** SKILL.md +- **Contributing files:** SKILL.md, references/formatting-and-scripts.md, references/validation-scripts.md - **Status:** `extracted` ## agentskills-quickstart diff --git a/plugins/kyberforge/.apm/skills/skill-audit/references/validation-scripts.md b/plugins/kyberforge/.apm/skills/skill-audit/references/validation-scripts.md new file mode 100644 index 0000000..89c7788 --- /dev/null +++ b/plugins/kyberforge/.apm/skills/skill-audit/references/validation-scripts.md @@ -0,0 +1,118 @@ +--- +source_keys: + - agentskills-spec + - agentskills-using-scripts +--- + +# Validation Scripts Reference + +Read this when a Step 1 script fails, cannot run, or reports something that needs interpreting. +Nothing here is needed on a clean run. + +## Report the gap, do not guess + +If a script cannot run at all — Bash denied, `python3` unavailable, PyYAML not importable, `vale` +not installed — say so as an **INFO** finding naming the script and the missing dependency, then +fall back to the manual checks below. An INFO never changes PASS/FAIL. Silently omitting the +dimension a script would have covered reports a clean audit that checked less than it claims to +have checked, and the Step 4 coverage line then names a dimension nothing actually examined. + +## Manual structural fallback + +`validate.sh` needs `python3` **and** PyYAML, and refuses to start without either — the description +value has to be measured after YAML folding is resolved, so skipping the ADR-0020 gates would be a +vacuous pass rather than a partial one. The two are checked separately, so the message already names +the right one — report it verbatim rather than diagnosing further: + +```text +Error: python3 is required but was not found on PATH. +Error: PyYAML is required but is not importable by python3. +``` + +Without them — or with Bash denied, or on a permission error — work this list +by hand and file the results under `### Structure` exactly as the script's output would have been: + +- **`name`** present, 1–64 characters, kebab-case (lowercase letters, digits and hyphens; no + leading, trailing or doubled hyphen), and **matching the skill's directory name** exactly. +- **`description`** present and non-empty; no unfilled `FILL IN:` placeholder in it. An absent or + empty description is a **FAIL**, never a silent skip — it is the one field preloaded into every + session, so a skill without one can never be routed to. +- **Description length**, measured on the folded YAML value with newlines collapsed to single + spaces — not on the raw block scalar, which counts indentation. 250 characters SUGGESTION, 400 + FAIL (ADR-0020), 1,024 FAIL (agentskills.io spec). +- **Body length**, counting everything after the frontmatter's closing `---`. 600 words + SUGGESTION, 900 FAIL (ADR-0020). +- **Whole-file ceilings**, counting the file including frontmatter: 500 lines FAIL, 2,770 words + FAIL (agentskills.io spec). These are a different measurement from the two above — report them + as separate findings, never merged. +- **A boundary clause is present** — either the prose form (`do not` / `instead` / `rather than` / + `not for`) or ADR-0020's compressed `Not -> ` arrow. **SUGGESTION**, not FAIL: + the absence is deterministic, but whether this skill warrants one is the auditor's call. +- **Boundary targets resolve** — **FAIL** on a name that resolves to nothing. See the section + below; resolving these by hand is the one item on this list with a procedure of its own. +- **Every `references/.md` named in the body exists on disk** — **FAIL**, not a suggestion. + A dispatch table or "read X" trigger naming a missing file sends the agent nowhere. Ignore + mentions inside fenced code blocks, and ignore a mention whose own line says the file is gone + (`removed`, `deleted`, `renamed`, `superseded`, `replaced`, `obsolete`, `deprecated`, `former`, + `gone`, `no longer`, `used to`) — that is a historical note, not a dispatch entry. +- **Gotchas discipline**, both **SUGGESTION**. Locate the section by a heading that *is* Gotchas + (`## Common Gotchas` counts; `## Gotcha handling` and `## Why gotchas matter` do not), running to + the next heading at the same level or shallower. More than five top-level entries is one + suggestion; a section over 25% of the body word count is a second, independent one. Count + entries at column 0 only — an indented child bullet is not an entry — and ignore fenced code + blocks for both. +- **No unfilled `FILL IN:` placeholder** anywhere in the body. +- **Every file in `scripts/`** carries the executable bit and contains no interactive prompt — + no bare `read`, no `select`, nothing that blocks on a TTY. + +## Resolving boundary targets by hand + +Targets are read from **both** boundary forms. The compressed `Not -> ` arrow and the +prose form are each parsed *and* target-checked, so a typo in prose phrasing fails exactly as an +arrow typo does — do not check only the names after an arrow. + +Build the universe by walking up **from the `SKILL.md` under audit**, never from the validator's own +location. The nearest ancestor holding `plugins/*/.apm/skills/` or `plugins/*/.apm/agents/` is the +authoring root, falling back to the nearest ancestor holding `.git`. When one is found the universe +is every skill and agent under `/plugins/*/`, plus the skill's own apm package, plus the +packages that package declares in its `apm.yml` under `dependencies.apm`. Deployed `.claude/` and +`.agents/` trees are consulted **only** when no authoring root exists — they are gitignored +`apm install` output, and reading them would make a fresh clone and a developer machine disagree. + +Three ways to read the result wrong: + +- **A hyphenated name used attributively is not a dangling target.** "Use pre-commit hooks instead + of ad-hoc scripts" reads as a route to `pre-commit` on wording alone. What separates a route from + prose is grammar: a route target is terminal — followed by punctuation, a conjunction, or a + boundary word — whereas a compound modifier is followed by the noun it modifies. A name followed + by an ordinary noun still *confirms* a route when it exists, but never raises a FAIL on its own. +- **A SUGGESTION-tier unresolved target is not a FAIL you may promote.** Terminal position alone is + not evidence of a route: "run `pre-commit` instead", "see `commit-msg`" and "use the clean-up + instead" are all terminal and all prose. A prose-form target earns a FAIL only when its own + sentence names another target that *does* resolve; otherwise the script reports it and moves on, + and so should you. Route notation — `/name` and `-> name` — is exempt and always FAILs, and it is + the fix to recommend when the author did mean a route. +- **`INFO boundary-target resolution DID NOT RUN` is not a pass.** The script prints it, and exits + 0, when no universe could be determined for that path — the usual cause being a skill copy + audited outside its package. Report it as an INFO naming the unchecked targets and re-run against + the real directory; filing it as clean signs off targets nothing verified. + +## Script-specific failures + +- **`validate-provenance.sh` printed nothing.** That is a pass, not a skip. It also exits 0 + silently when the skill has no `source_keys` and no `references/sources.md` — nothing to + validate is not a finding. +- **`vale` reports `0 files`.** Treat the pass as NOT RUN, not as clean, and fall back to full + Step 3 judgment for the dimensions it would have covered. The bundled `Kyberforge` style is + scoped by glob in `assets/vale/.vale.ini`; a file outside those globs is silently not linted. +- **`E100 Runtime error ... does not exist` (exit 2) from `vale-wrap.sh`.** An explicit relative + `--config` was passed. Pass none: the wrapper locates its own `assets/vale/.vale.ini` from its + own path, so a resolved script path plus an unresolved config path produces exactly this. Do not + read this exit code as vale being unavailable — that misreading sends the audit down the + fallback path while vale was installed and working the whole time. +- **The `vale` binary is genuinely absent** (`command not found`). Report one INFO naming it, then + fall back to full Step 3 judgment for the description, body-discipline and patterns dimensions — + the prefilter's whole coverage. Judge those by rubric rather than dropping them. +- **A path argument that does not exist is a hard error** in `vale-wrap.sh`, deliberately: bare + `vale` would fall back to reading stdin and print a clean-looking `0 errors ... in stdin`, which + the `0 files` guard above does not catch. diff --git a/plugins/kyberforge/.apm/skills/skill-audit/scripts/validate.sh b/plugins/kyberforge/.apm/skills/skill-audit/scripts/validate.sh index 0c0473e..f9c0df7 100755 --- a/plugins/kyberforge/.apm/skills/skill-audit/scripts/validate.sh +++ b/plugins/kyberforge/.apm/skills/skill-audit/scripts/validate.sh @@ -11,7 +11,7 @@ Arguments: skill-dir Path to the skill directory containing SKILL.md. Exit codes: - 0 All checks passed + 0 All checks passed (may include SUGGESTIONs) 1 One or more checks failed EOF } @@ -28,10 +28,36 @@ if [[ $# -lt 1 ]]; then exit 1 fi +# PyYAML is a HARD dependency, not a nice-to-have. The description VALUE has to +# be measured after YAML folding is resolved, and the hand-rolled reader that +# used to stand in for PyYAML disagreed with it across the 400-character FAIL +# boundary — same description, two verdicts, depending on which reader ran. +# Refusing to start is the only honest option; the repo's jq / apm / vale +# dependencies are declared the same way. +# Check the interpreter separately from the library: `python3 -c` fails the same +# way whether python3 is missing or PyYAML is, and reporting the wrong missing +# dependency sends the reader to install the wrong thing. +if ! command -v python3 > /dev/null 2>&1; then + echo "Error: python3 is required but was not found on PATH." >&2 + echo " Why: skipping the ADR-0020 description, body and boundary-target gates would be a vacuous pass." >&2 + echo " Fix: install python3 (pre-commit itself is a Python application, so it is almost certainly already present)." >&2 + exit 1 +fi + +if ! python3 -c 'import yaml' > /dev/null 2>&1; then + echo "Error: PyYAML is required but is not importable by python3." >&2 + echo " Why: skipping the ADR-0020 description, body and boundary-target gates would be a vacuous pass." >&2 + echo " Fix: python3 -m pip install PyYAML (or your distro's python3-yaml package)." >&2 + exit 1 +fi + python3 -u - "$1" <<'PYTHON' import sys import os import re +import glob + +import yaml skill_dir = os.path.abspath(sys.argv[1]) skill_md = os.path.join(skill_dir, "SKILL.md") @@ -40,42 +66,837 @@ if not os.path.isfile(skill_md): print(f"Error: '{skill_md}' not found.", file=sys.stderr) sys.exit(1) -with open(skill_md) as f: - content = f.read() - failed = False +suggestions = [] def ok(msg): print(f"PASS {msg}") def fail(msg): + # stderr, matching scripts/skill-size-check.sh's ERROR routing. All three + # scripts in the ADR-0020 family now agree: findings that fail the run go to + # stderr, everything advisory (PASS / SUGGESTION / INFO) goes to stdout. + # Both repo callers capture `2>&1`, so nothing a human reads moves. global failed - print(f"FAIL {msg}") + print(f"FAIL {msg}", file=sys.stderr) failed = True +def suggest(msg): + # SUGGESTIONs are printed after every check and NEVER touch the exit code. + # skill-audit's Step 4 report counts them into its `PASS (N suggestions)` + # result line, which is what makes the ADR-0020 SUGGESTION tier visible + # rather than another silently-ignored warning (ADR-0013). + suggestions.append(msg) + +def info(msg): + # A check that DECLINED to run says so out loud, rather than passing + # silently. Silence is what let a whole gate family go missing unnoticed. + print(f"INFO {msg}") + + +# ===== BEGIN ADR-0020 SHARED BOUNDARY RESOLVER ===== +# ONE resolver, embedded VERBATIM in three scripts: +# scripts/skill-size-check.sh +# plugins/kyberforge/.apm/skills/skill-audit/scripts/validate.sh +# plugins/kyberforge/.apm/skills/agent-audit/scripts/validate.sh +# The block between these markers must stay byte-identical in all three. It is +# copied rather than imported because a cache-installed plugin's scripts cannot +# read files outside their own plugin directory, so there is no single file all +# three can share (same constraint that forces the ADR-0020 constants to be +# duplicated). Edit one copy, then paste it over the other two. +# +# Requires: glob, os, re, yaml (imported by the host script; PyYAML is a hard +# dependency, preflighted in bash before the interpreter starts). + +# --- Input ---------------------------------------------------------------- +# Every file this resolver's callers read goes through read_text(), which pins +# UTF-8 explicitly instead of inheriting locale.getpreferredencoding(). Under +# LC_ALL=C that inherited encoding is ASCII, so a perfectly ordinary em dash in +# a SKILL.md aborted the run with a bare UnicodeDecodeError traceback — loud, +# but pointing at the interpreter rather than at the file or the fix. A file +# that genuinely is not UTF-8 still fails; it just says so. + + +class EncodingError(Exception): + pass + + +def read_text(path): + """File contents as text, UTF-8, with a diagnostic instead of a traceback.""" + try: + with open(path, encoding='utf-8') as fh: + return fh.read() + except UnicodeDecodeError as exc: + raise EncodingError( + "not valid UTF-8 (%s at byte %d) — re-save the file as UTF-8; " + "this gate does not guess at other encodings" + % (exc.reason, exc.start)) + + +# --- Universe ------------------------------------------------------------ +# The set of names a boundary clause may resolve against is derived from an +# AUTHORING ROOT found by walking up FROM THE TARGET FILE. It is NEVER derived +# from this script's own location: deriving it from ${BASH_SOURCE} leaked +# holocron's 39-skill universe into every consumer repo that ran this hook +# through pre-commit, so a consumer skill routing to `skill-audit` resolved +# against a plugin it had never installed. +# +# An authoring root is the nearest ancestor holding plugins/*/.apm/skills/ or +# plugins/*/.apm/agents/ (a plugin monorepo), falling back to the nearest +# ancestor holding .git. When one is found the universe is: +# 1. every skill and agent under /plugins/*/ — sibling plugins resolve, +# which is what a monorepo means, +# 2. the target's own apm package, +# 3. the packages that package DECLARES in apm.yml dependencies.apm. +# Deployed .claude/ and .agents/ trees are deliberately NOT consulted when the +# root came from the plugins/ probe. They are `apm install` output, gitignored, +# and present only on a machine that has run it: four cross-plugin targets in +# this repo (gitea-branches -> git-branches, gitea-branches -> git-history, +# gitea-issues -> git-branches, gitea-workflow -> git-workflow) resolved through +# .claude/skills/ alone, so the same commit measured 2 dangling targets on a +# developer machine and 6 on a fresh clone. A gate shipping hot with no baseline +# cannot give two answers. +# +# Deployed trees ARE used when no plugin monorepo was found — whether the walk +# landed on a bare .git ancestor or on nothing at all. That is the consumer +# case: the file being checked lives in or beside a deployed tree, inside an +# ordinary git repo, with no monorepo to read. The two cases are told apart by +# which probe matched, never by how many names a root contributed; see +# known_targets(). + + +def _is_fs_root(path): + return os.path.dirname(path) == path + + +def _collect_package(pkg_dir, names): + """Add every skill/agent name a package directory exposes, any layout.""" + # glob.escape() the DIRECTORY only. A checkout path containing `[`, `]`, + # `*` or `?` — a worktree named `feature[2]`, say — otherwise turns the + # whole pattern into a character class that matches nothing, and the + # resolver degrades to the "DID NOT RUN" INFO with rc=0 across every file + # in the tree. The wildcards in `sub` are the intended ones and stay raw. + safe_dir = glob.escape(pkg_dir) + for sub in ('.apm/skills/*/', 'skills/*/'): + for path in glob.glob(os.path.join(safe_dir, sub)): + names.add(os.path.basename(path.rstrip('/')).lower()) + for sub in ('.apm/agents/*.md', 'agents/*.md'): + for path in glob.glob(os.path.join(safe_dir, sub)): + base = os.path.basename(path) + if base.endswith('.agent.md'): + base = base[:-len('.agent.md')] + else: + base = base[:-len('.md')] + names.add(base.lower()) + + +def _apm_package_root(start_dir): + """Nearest ancestor that is an apm package root (apm.yml or .apm/). + + The filesystem root is never a candidate: a stray /.apm/skills/ — a + scaffolding test's leftover, say, and one really does exist on at least one + machine here — would otherwise become the package root of every path on it. + Capped at ten levels so a pathological path can't become a filesystem + crawl; that covers every real layout by a wide margin. + """ + current = os.path.abspath(start_dir) + for _ in range(10): + if _is_fs_root(current): + return None + if (os.path.isfile(os.path.join(current, 'apm.yml')) + or os.path.isdir(os.path.join(current, '.apm'))): + return current + current = os.path.dirname(current) + return None + + +def _authoring_root(start_dir): + """Nearest ancestor that is a plugin monorepo, else the nearest .git tree. + + Returns (root, matched_plugins_probe). The flag reports WHICH probe + matched: True for the plugins/*/.apm/{skills,agents} glob, False for the + .git fallback and for no match at all. known_targets() needs that + distinction — only a real plugins/ root makes the deployed trees + redundant, and a name-count delta cannot tell the two apart. + + Two passes, not one interleaved walk: a nested .git (a submodule, a + worktree of a sub-package) must not win over a real plugins/ root further + up. Both passes stop before the filesystem root for the same reason + _apm_package_root does. + """ + probes = ( + lambda d: bool(glob.glob(os.path.join(glob.escape(d), 'plugins', '*', '.apm', 'skills')) + or glob.glob(os.path.join(glob.escape(d), 'plugins', '*', '.apm', 'agents'))), + lambda d: os.path.exists(os.path.join(d, '.git'))) + for index, probe in enumerate(probes): + current = os.path.abspath(start_dir) + for _ in range(12): + if _is_fs_root(current): + break + if probe(current): + return current, index == 0 + current = os.path.dirname(current) + return None, False + + +def _collect_authoring_root(root, names): + """Every plugin in the monorepo contributes its names.""" + for pkg in glob.glob(os.path.join(glob.escape(root), 'plugins', '*')): + if os.path.isdir(pkg): + _collect_package(pkg, names) + + +def _declared_dependency_dirs(pkg_dir): + """Directories of the apm packages pkg_dir's manifest DECLARES. + + Reads dependencies.apm and resolves each entry to a directory on disk: + a monorepo-relative `path:` (against the package root and the nearest + ancestor manifest, which is the monorepo root) or an installed + apm_modules//. Entries that resolve to nothing are skipped — an + undeployed dependency contributes no names rather than an error. + """ + manifest = os.path.join(pkg_dir, 'apm.yml') + if not os.path.isfile(manifest): + return [] + try: + data = yaml.safe_load(read_text(manifest)) or {} + except Exception: + return [] + if not isinstance(data, dict): + return [] + deps = data.get('dependencies') + deps = deps.get('apm') if isinstance(deps, dict) else None + if not isinstance(deps, list): + return [] + + roots = [pkg_dir] + ancestor = os.path.dirname(os.path.abspath(pkg_dir)) + for _ in range(10): + if _is_fs_root(ancestor): + break + if os.path.isfile(os.path.join(ancestor, 'apm.yml')): + roots.append(ancestor) + break + ancestor = os.path.dirname(ancestor) + + found = [] + for entry in deps: + candidates = [] + if isinstance(entry, dict): + rel = entry.get('path') + name = entry.get('name') + if not name and rel: + name = os.path.basename(str(rel).rstrip('/')) + if rel: + candidates.extend(os.path.join(r, str(rel)) for r in roots) + if name: + candidates.append(os.path.join(pkg_dir, 'apm_modules', str(name))) + elif isinstance(entry, str): + name = re.split(r'[#@]', entry)[0].strip().rstrip('/').split('/')[-1] + if name: + candidates.append(os.path.join(pkg_dir, 'apm_modules', name)) + candidates.extend(os.path.join(r, 'plugins', name) for r in roots) + for candidate in candidates: + if os.path.isdir(candidate): + found.append(candidate) + return found + + +def _deployed_roots(start_dir): + """.claude/ and .agents/ trees above start_dir — what a host really sees. + + Consulted ONLY when no plugin monorepo root was found; see the header. The + filesystem root is skipped for the same reason _apm_package_root skips it: + a stray /.claude/skills/ must not join every path's universe. + """ + found = [] + current = os.path.abspath(start_dir) + for _ in range(10): + if _is_fs_root(current): + break + for name in ('.claude', '.agents'): + base = os.path.join(current, name) + if os.path.isdir(base): + found.append(base) + current = os.path.dirname(current) + return found + + +def known_targets(start_dir): + """Every skill/agent name a boundary clause in start_dir may name.""" + names = set() + start = os.path.abspath(start_dir) + + # Siblings: a cache-installed plugin and a deployed .claude/skills/ tree + # both put peers one level up, with no plugins/ directory above them. The + # grandparent is guarded against the filesystem root exactly like the two + # walk-up loops above — for a start dir of /skills/ the grandparent is + # `/`, and collecting there picks up this machine's stray /.apm/skills/. + parent = os.path.dirname(start) + grandparent = os.path.dirname(parent) + if (os.path.basename(parent) in ('skills', 'agents') + and os.path.isdir(parent) and not _is_fs_root(grandparent)): + _collect_package(grandparent, names) + + package = _apm_package_root(start) + if package: + _collect_package(package, names) + for dep_dir in _declared_dependency_dirs(package): + _collect_package(dep_dir, names) + + # A .git ancestor is an authoring root only if it actually holds plugins. + # _authoring_root() falls back to the nearest .git, so it is truthy in ANY + # git repo; without the distinction that fallback wins in every consumer + # checkout, _collect_authoring_root() contributes nothing, and the deployed + # branch below is dead code in the exact case it exists for. So condition + # on WHICH probe matched, which _authoring_root() reports directly. A + # name-count delta looks equivalent and is not: _collect_authoring_root() + # re-collects the checked file's own plugin, whose names the blocks above + # already added, so a one-plugin monorepo shows a delta of zero and would + # wrongly reach for the deployed trees — including the user's global + # ~/.claude/skills, making the verdict depend on what happens to be + # installed (ADR-0020 lines 118-127). + root, root_has_plugins = _authoring_root(start) + if root: + _collect_authoring_root(root, names) + if not root_has_plugins: + for base in _deployed_roots(start): + _collect_package(base, names) + return names + + +# --- Extraction ----------------------------------------------------------- +# False positives are the design constraint here, not recall. The rules: +# * A BARE target must be hyphenated AND sit in a boundary sentence (one +# carrying "do not"/"instead"/"rather than"/"not for"). Without the second +# condition, pc-run's "run pre-commit hooks" reads as a route to a +# non-existent `pre-commit` skill. +# * A BARE arrow target counts only in ADR-0020's compressed boundary form, +# `Not -> `. Without that, diagnose's process chain +# "fix -> regression-test" reads as a route to `regression-test`. +# * A backticked hyphenated token counts only inside a boundary sentence. +# Unconditionally, `pre-push` or `commit-msg` in a TRIGGER clause is a hard +# FAIL with no escape hatch. Gating it costs nothing (measured over this +# corpus: 54 targets before and after); DELETING it costs 7 real targets +# across three gitea skills, so it is gated, not removed. +# * SINGLE-WORD targets are deliberately NOT matchable bare — `research`, +# `triage`, `forge`, `prototype` and `tdd` are all real skill names and all +# ordinary English, so a bare-word rule would flag most of the corpus. A +# single-word target must be written `` `forge` `` or /forge to be seen. +# That is a known recall limitation, accepted over the false positives. +# Tool names (Read/Write/Edit) are excluded by the lowercase-only pattern; MCP +# tool names (issue_write) by its rejection of underscores; file names by its +# rejection of dots and slashes. +# +# ATTRIBUTIVE USE. The boundary-sentence gate above does NOT solve the +# `pre-commit` false positive, and the comment that claimed it did was wrong: +# "instead", "rather than", "do not" and "not for" are exactly the words a +# boundary clause uses, so the gate is open precisely where the risk is. All of +# these were hard dangling FAILs with no suppression: +# Use pre-commit hooks instead of ad-hoc scripts. +# Invoke the pull-request template instead of writing one by hand. +# Use conventional-commits formatting rather than free-form messages. +# Composes label-resolution logic instead of duplicating it. +# Do not use for X — run the `pre-push` hooks instead. +# What separates every one of them from a real route is grammar, not marking: +# the hyphenated token is a compound MODIFIER of the noun that follows it +# ("pre-commit hooks", "pull-request template"), where a route target is +# terminal — followed by punctuation, a conjunction, or a boundary word. So a +# target whose next token is an ordinary lowercase noun is CONFIRM-ONLY: it +# still resolves and still counts as a route when the name exists, but it can +# never raise a dangling error on its own. +# +# This is deliberately NOT the simpler "only marked targets may dangle" rule, +# which would have been wrong here: BOTH live true positives in this corpus are +# BARE — research's "(use neuledge-context)" and gitea-issues' "Composes +# gitea-labels-\n milestones", where the `>` fold yields "gitea-labels- +# milestones" and the trailing hyphen is what keeps it terminal. Marking is a +# poor proxy, so the follower token is the signal, and it is applied to +# backticked targets too. +# +# TERMINAL IS NOT ENOUGH — IN-SENTENCE CORROBORATION. The follower test clears +# `pre-push` in the example above only because that example happens to be +# followed by the noun "hooks". Move the same token into terminal position and +# it was a hard FAIL again, with no suppression mechanism anywhere in this gate: +# Do not use for running hooks — run `pre-commit` instead. +# Do not use for the commit message — see `commit-msg`. +# Do not use for type errors — run `type-check` first. +# Instead, use `semantic-release`. +# Do not use for the old flow — use the clean-up instead. +# Do not run end-to-end, run unit-tests. +# Every one of those is grammatically identical to a genuinely broken route: +# "route verb + hyphenated name + terminal" is also exactly how prose cites a +# tool, a hook, a file format or an English compound. Nothing local separates +# them, and the skills most exposed are the ones this contract sends authors +# back to rewrite first — pc-run, pc-author, vale-run, vale-config and the apm-* +# family are all ABOUT hyphenated tools. +# +# So the confidence to BLOCK a commit comes from the sentence, not the token: a +# prose-form target may raise a hard error only when its own sentence names at +# least one OTHER target that RESOLVES. A routing sentence proves itself by +# routing somewhere real; a lone unresolvable name proves nothing. That is not a +# rule fitted to the fixtures — it is the shape of both live true positives, +# which sit beside `write-docs` and `gitea-labels-milestones` respectively, and +# it changes this corpus's verdict by exactly nothing. +# +# An uncorroborated unresolvable target is NOT discarded: every caller reports +# it at its SUGGESTION tier, naming the target. The finding stays visible on +# every run; only the power to block a commit is withdrawn, which is the part +# that had no escape hatch. +# +# EXPLICIT ROUTE NOTATION is exempt from corroboration and always blocks: +# ADR-0020's compressed arrow (`Not -> `) and Claude Code's +# invocation form (`/`). Neither is ever how English cites a tool — nobody +# writes `-> pre-commit` or `/pre-commit` to mean the hook — so there is no +# ambiguity to resolve, and an author who wants a route checked unconditionally +# has two ways to say so. +# +# NAMESPACE: `plugin:skill` is live in this repo (native user-scope installs +# still resolve `gitea:gitea-prs`), so the patterns admit an optional +# `:` prefix and normalize_target() strips it before resolution. +NS = r"(?:[a-z0-9]+(?:-[a-z0-9]+)*:)?" +NAME_ANY = NS + r"[a-z0-9]+(?:-[a-z0-9]+)*" +NAME_HYPH = NS + r"[a-z0-9]+(?:-[a-z0-9]+)+" +ROUTE_VERB = (r"(?:use|uses|using|run|runs|invoke|invokes|invoking|try|see" + r"|that'?s|compose|composes|call|calls" + r"|routes?\s+to|delegates?\s+to|prefers?|switch(?:es)?\s+to" + r"|hands?\s+off\s+to)") +MARKED_TARGET = r"(?:`/?(%s)`|(?|→)\s*%s" % MARKED_TARGET, re.I) +ARROW_BOUNDARY = re.compile(r"\bnot\b[^.;]*?(?:->|→)\s*(%s)\b" % NAME_HYPH, re.I) +BACKTICK = re.compile(r"`(%s)`" % NAME_HYPH, re.I) +# A boundary clause takes two shapes and BOTH count: the prose markers, and +# ADR-0020's compressed arrow form `Not -> `. +BOUNDARY_MARKER = re.compile(r"\b(?:do\s+not|instead|rather\s+than|not\s+for)\b", re.I) +BOUNDARY_ARROW = re.compile(r"\bnot\b[^.;]*?(?:->|→)", re.I) +# Sentence boundaries decide the CORROBORATION scope above, so getting one wrong +# is not cosmetic — it moves a target between SUGGESTION and blocking ERROR. Two +# shapes common in these descriptions defeat the naive "period, space, capital" +# rule, in OPPOSITE directions: +# OVER-SPLIT. `e.g. "set up the manifest"` ends no sentence, but the quote +# looks like one starting. The clause is cut in half, the corroborating +# target lands on the far side of the cut, and a genuinely dangling target +# silently demotes to SUGGESTION — the gate takes a measurement and then +# throws it away, which is the vacuous-green shape this file exists to stop. +# UNDER-SPLIT. A real sentence opening with a code span or a lowercase skill +# name ("... Composes it. `gitea-prs` also uses it.") is not seen as a start +# at all, so two sentences merge and a resolving target vouches for an +# unresolvable one it never stood beside — a hard FAIL with no escape hatch, +# which is exactly the failure the corroboration rule was added to prevent. +# Both are closed here: the five abbreviations that actually occur in routing +# prose are excluded as sentence ends, and the opener class admits a backtick or +# a lowercase letter. Verified zero-delta on the current corpus (37 ERROR / 58 +# SUGGESTION / 2 dangling before and after) — this protects the descriptions +# issue #99 is about to rewrite, not the ones already measured. +SENTENCE_SPLIT = re.compile( + u'(? name` (ADR-0020's compressed boundary + form, passed in by the caller that matched the arrow). A backticked name + does NOT qualify — a code span is how a tool, a file and a skill are all + cited, so it carries no intent the follower test hasn't already read. + """ + return arrow or (start > 0 and text[start - 1] == '/') + + +def _add(out, text, name, start, end, strict=None, arrow=False): + if not name: + return + out.append((name, + _terminal(text, end) if strict is None else strict, + _notation(text, start, arrow))) + + +def _scan(text, route_re, cont_re, out): + for match in route_re.finditer(text): + name, start, end = _first(match) + if not name: + continue + _add(out, text, name, start, end) + # "use git-history or git-branches instead" / "use gitea-issues / + # gitea-prs" — keep consuming conjoined targets after the first. + pos = match.end() + while True: + cont = cont_re.match(text, pos) + if not cont: + break + _add(out, text, *_first(cont)) + pos = cont.end() + + +def _extract_sentence(sentence): + """[(name, may_dangle, notation)] for the routing targets in ONE sentence. + + Kept separate from _extract() because corroboration is scoped to a single + sentence: a target's evidence is what stands beside it, not what the rest of + the description happens to mention. + """ + out = [] + boundary = bool(BOUNDARY_MARKER.search(sentence)) + _scan(sentence, + ROUTE_ANY if boundary else ROUTE_MARKED, + CONT_ANY if boundary else CONT_MARKED, + out) + for match in ARROW_MARKED.finditer(sentence): + # `-> name` and `-> /name` are route notation, not prose: nothing + # reads as a compound modifier after an arrow, so no follower test. + _add(out, sentence, *_first(match), strict=True, arrow=True) + for match in ARROW_BOUNDARY.finditer(sentence): + _add(out, sentence, match.group(1), match.start(1), match.end(1), + strict=True, arrow=True) + if boundary: + for match in BACKTICK.finditer(sentence): + _add(out, sentence, match.group(1), match.start(1), match.end(1)) + return out + + +def _extract(description): + """[(name, may_dangle, notation)] for every routing target.""" + out = [] + for sentence in SENTENCE_SPLIT.split(description): + out.extend(_extract_sentence(sentence)) + return out + + +def boundary_targets(description): + """Every routing target, for reporting and for confirming a route.""" + return sorted({name for name, _, _ in _extract(description)}) + + +def unresolved_targets(description, known): + """Targets resolving to nothing, split into (blocking, reported). + + `blocking` earns a hard error; `reported` is SUGGESTION tier — named on + every run, never fatal. Three conditions gate the promotion, and all of them + are documented at length in the ATTRIBUTIVE USE and CORROBORATION notes + above: + + 1. the target must be terminal, not a compound modifier ("pre-commit + hooks" is prose about a tool, not a route), + 2. it must be written in route notation (`/name`, `-> name`), OR + 3. its own sentence must name another target that DOES resolve. + + Everything else is reported and left alone. `known` is the resolved + universe from known_targets(); passing an empty set is not meaningful — + callers check for that first and decline out loud instead. + """ + blocking, reported = set(), set() + for sentence in SENTENCE_SPLIT.split(description): + found = _extract_sentence(sentence) + resolved = {normalize_target(name) for name, _, _ in found + if normalize_target(name) in known} + for name, may_dangle, notation in found: + key = normalize_target(name) + if key in known or not may_dangle: + continue + if notation or (resolved - {key}): + blocking.add(name) + else: + reported.add(name) + return sorted(blocking), sorted(reported - blocking) + +# --- Frontmatter ---------------------------------------------------------- +# Tolerant on the way in, HARD-FAILING on the way out. A UTF-8 BOM, a leading +# blank line, trailing whitespace after either `---`, or CRLF line endings all +# defeated the old `^---\n(.*?)\n---`, and the miss was SILENT: every ADR-0020 +# check was skipped and the file reported green (measured: a 550-character +# description with a 1,000-word body exited 0 behind a BOM). A file that cannot +# be measured must never report green, so every caller of these two ERRORs on a +# miss instead of moving on. +# +# The CLOSING marker is anchored at column 0 — deliberately NOT `[ \t]*---`. +# YAML block-scalar content must be indented deeper than its key, so an +# indented `---` inside a folded description is CONTENT; letting it close the +# frontmatter truncated the description mid-value and silently reclassified the +# rest as body, which is a vacuous green in both directions at once. Leading +# whitespace is still tolerated on the OPENING marker, where no such content +# can exist. +FRONTMATTER_RE = re.compile( + r'^[ \t\r\n]*---[ \t]*\r?\n(.*?)\r?\n---[ \t]*(?:\r?\n|\Z)', re.DOTALL) + + +def strip_bom(text): + return text[1:] if text.startswith(u'') else text + + +class FrontmatterError(Exception): + pass + + +def description_value(fm_text): + """The description VALUE, with YAML folding resolved. + + PyYAML is a HARD requirement, preflighted in bash. The hand-rolled fallback + this replaced diverged from a real parser across the FAIL boundary — one + corpus description measured 270 characters parsed and 412 unparsed, and a + quoted `"description"` key or an explicit `description: null` returned empty + from it, silently skipping the description AND routing checks. A gate that + disagrees with itself depending on which reader ran is worse than no gate. + + This is the ONLY reader any of the three scripts may use to decide whether a + description is present. A line regex cannot: `description:` with no value + followed by `model: sonnet` lets `\\s*` cross the newline and captures the + NEXT key, which reads as a non-empty description, skips the "missing or + empty" failure, and then early-returns out of every ADR-0020 gate on the + genuinely empty folded value. That combination exited 0 with zero output on + a BLOCKING pre-push gate. + """ + try: + data = yaml.safe_load(fm_text) + except Exception as exc: + # Every FrontmatterError message is a COMPLETE clause, never a detail a + # caller wraps in one. Callers used to prefix a hard-coded "frontmatter + # is not valid YAML (...)", which is true only of this branch: the two + # type failures below come from frontmatter that parsed fine, and + # telling their author the YAML is invalid sends them hunting for a + # syntax error that is not there — on a blocking gate with no baseline. + raise FrontmatterError('frontmatter is not valid YAML (%s)' + % re.sub(r'\s+', ' ', str(exc)).strip()) + if not isinstance(data, dict): + raise FrontmatterError('frontmatter is not a YAML mapping') + value = data.get('description') + if value is None: + return '' + if not isinstance(value, str): + # NOT str()-coerced. `description: true` became the 4-character "True" + # and sailed through the 400-character gate; a list or mapping was + # measured as its Python repr. Neither is a description a host can + # preload, so this is a parse failure, reported as one. + raise FrontmatterError( + 'description is a %s, not a string' % type(value).__name__) + return re.sub(r'\s+', ' ', value).strip() + + +# --- Body-shape checks (skills only; agents have no references/ dir) ------- +# Deterministic and countable, so they are enforced here. Whether a given +# gotcha is WARRANTED is semantic and stays the auditor's judgment, which is why +# both gotcha checks are SUGGESTION tier. A missing reference file is not a +# style opinion — it is a broken pointer — so that one is ERROR tier. +# +# Both read a FENCE-MASKED copy of the body. Scanning the raw body made a +# ```-fenced example a hard ERROR — and the skills most likely to carry one are +# skill-author and skill-audit, which DOCUMENT the references/ convention — and +# let a `## Gotchas` heading inside a fenced block stand in for the real +# section. Masking preserves every byte offset (content becomes spaces, +# newlines stay), so a span found in the mask slices the original. +GOTCHA_MAX_ENTRIES = 5 +GOTCHA_MAX_BODY_FRACTION = 0.25 +# The heading has to BE "Gotchas", not merely contain the word: `## Gotcha +# handling` and `## Why gotchas matter` are prose sections, and treating one as +# the Gotchas section measured a span that was never a gotcha list. +GOTCHA_HEADING = re.compile(r'^(#{1,6})[ \t]+(?:[^\n]*?[ \t])?gotchas?[ \t]*:?[ \t]*$', + re.I | re.M) +# Column 0 only. `^[ \t]{0,3}` counted a two-space-indented CHILD bullet as a +# top-level entry, so a five-entry section with sub-bullets reported nine. +GOTCHA_ENTRY = re.compile(r'^(?:[-*+]|\d+[.)])[ \t]+', re.M) +FENCE_OPEN = re.compile(r'^[ \t]{0,3}(`{3,}|~{3,})') +REFERENCE_POINTER = re.compile( + r'(?= len(fence) + and not stripped.strip()[len(marker):].strip()): + fence = None + # An UNCLOSED fence has no cost-free answer, only a choice of which way to + # be wrong. Masking to end-of-body blanks the rest of the body, silently + # disabling the ERROR-tier references/ check and the gotcha counts. + # Returning the raw text instead exposes the unclosed example's own + # content, so a fenced example naming a nonexistent references/ file + # becomes a hard ERROR it would not have been had the fence been closed — + # confirmed, not hypothetical. The loud-false-positive direction is the one + # chosen: this script's rule is that a file it cannot measure must never + # report green, and masking-onward is exactly that failure. Both outcomes + # need an already-malformed file, and the false positive costs one fence. + if fence is not None: + return text + return ''.join(out) + + +def gotcha_stats(body): + """(entry count, section word count) for the first Gotchas section, or None. + + The section runs to the next heading at the same level or shallower. + Entries are top-level list items; a section written as subheadings instead + of a list counts those. Headings and entries are read from the fence mask; + the word count is taken from the original slice, because fenced lines are + real body words and the fraction is measured against the whole body. + """ + masked = mask_fenced(body) + match = GOTCHA_HEADING.search(masked) + if not match: + return None + level = len(match.group(1)) + rest = masked[match.end():] + nxt = re.search(r'^#{1,%d}[ \t]+' % level, rest, re.M) + end = match.end() + (nxt.start() if nxt else len(rest)) + section = masked[match.end():end] + entries = len(GOTCHA_ENTRY.findall(section)) + if entries == 0 and level < 6: + entries = len(re.findall(r'^#{%d,6}[ \t]+' % (level + 1), section, re.M)) + return entries, len(body[match.end():end].split()) + + +def missing_reference_pointers(body, skill_dir): + """references/.md named in the body but absent from disk.""" + masked = mask_fenced(body) + missing = set() + for match in REFERENCE_POINTER.finditer(masked): + start = masked.rfind('\n', 0, match.start()) + 1 + end = masked.find('\n', match.end()) + if end < 0: + end = len(masked) + if REFERENCE_PAST.search(masked[start:end]): + continue + if REFERENCE_QUALIFIER.search(masked[start:match.start()]): + continue + if not os.path.isfile(os.path.join(skill_dir, 'references', match.group(1))): + missing.add('references/' + match.group(1)) + return sorted(missing) +# ===== END ADR-0020 SHARED BOUNDARY RESOLVER ===== + + +# A leading BOM is stripped before anything is parsed or counted. It changes +# neither count below — it is not a line separator and str.split() does not +# treat it as whitespace — but it did defeat the frontmatter match. +try: + content = strip_bom(read_text(skill_md)) +except EncodingError as exc: + fail(f"SKILL.md is {exc}. Nothing downstream can be measured, so this is a " + f"hard failure, not a skip") + print("One or more checks failed.") + sys.exit(1) + # --- Parse frontmatter --- -fm_match = re.match(r'^---\n(.*?)\n---', content, re.DOTALL) +fm_match = FRONTMATTER_RE.match(content) if not fm_match: - fail("No valid YAML frontmatter block found (expected ---...---)") + fail("No parseable YAML frontmatter block found. Expected a `---` line, the " + "fields, then a closing `---` line (a BOM, leading blank lines, trailing " + "spaces after either marker and CRLF endings are all tolerated). Nothing " + "downstream can be measured, so this is a hard failure, not a skip") + print("One or more checks failed.") sys.exit(1) fm = fm_match.group(1) body_start = fm_match.end() -# Extract name -name_m = re.search(r'^name:\s*(\S+)', fm, re.MULTILINE) +# Extract name. The character class is `[ \t]`, never `\s`: under re.MULTILINE +# a `\s*` after the colon crosses the newline, so a valueless `name:` followed +# by `description: ...` captured the NEXT KEY as the name and reported a +# mismatch instead of an absence. Same class of bug as the `description:` one +# the shared resolver's description_value() docstring records. +name_m = re.search(r'^name:[ \t]*(\S+)', fm, re.MULTILINE) name = name_m.group(1).strip('"\'') if name_m else "" -# Extract description — inline or block scalar (> or |) -desc = "" -desc_m = re.search(r'^description:\s*([>|])\n((?:[ \t]+.+\n?)+)', fm, re.MULTILINE) -if desc_m: - raw = desc_m.group(2) - desc = re.sub(r'\s+', ' ', raw).strip() -else: - desc_inline = re.search(r'^description:\s*(.+)', fm, re.MULTILINE) - if desc_inline: - desc = desc_inline.group(1).strip() +# Extract description — the VALUE, with YAML folding resolved. Most of this +# corpus writes descriptions as `>`-folded block scalars, so the raw lines +# carry indentation and newlines that are not part of the value: every length +# measurement below is wrong unless the scalar is folded first. +try: + desc = description_value(fm) +except FrontmatterError as exc: + # `exc` carries the whole clause — invalid YAML, a non-mapping block, or a + # description of the wrong type. Do not prefix a diagnosis here; the last + # one named a syntax error for two failures that have none. + fail(f"{exc}. Nothing downstream can be measured, so this is a hard " + f"failure, not a skip") + print("One or more checks failed.") + sys.exit(1) dir_name = os.path.basename(skill_dir) @@ -104,21 +925,23 @@ if name: # name format if name: if re.match(r'^[a-z0-9]+(-[a-z0-9]+)*$', name): - ok(f"name format valid (kebab-case)") + ok("name format valid (kebab-case)") else: fail(f"name '{name}' is invalid — use lowercase letters, numbers, and hyphens only; no leading, trailing, or consecutive hyphens") # description present if desc: - ok(f"description present") + ok("description present") else: fail("description field is missing or empty") -# description length +# description length — agentskills.io spec backstop. UNCHANGED by ADR-0020: +# 1024 is the specification's hard limit, and the ADR-0020 budget gate below +# sits underneath it rather than replacing it. if desc: dlen = len(desc) if dlen <= 1024: - ok(f"description length {dlen} chars (limit: 1024)") + ok(f"description length {dlen} chars (agentskills.io spec limit: 1024)") else: fail(f"description length {dlen} chars — exceeds 1024-character limit") @@ -146,6 +969,26 @@ MAX_LINES = 500 # for the full measurement. MAX_WORDS = 2770 +# ADR-0020 context-budget gates. DUPLICATED from scripts/skill-size-check.sh +# for exactly the same cache-isolation reason as MAX_LINES/MAX_WORDS above, and +# carrying the same warning — tests/test-skill-size-check.sh asserts the copies +# agree, so drift fails CI instead of shipping an audit that disagrees with the +# commit hook. agent-audit/scripts/validate.sh holds a third copy of the two +# description constants; per ADR-0020 agents take the description gates and +# deliberately take NO body word gate, because an agent body becomes the system +# prompt of a fresh context rather than competing with a live conversation. +# +# These are NOT the same measurements as MAX_LINES/MAX_WORDS and must not be +# unified with them: MAX_WORDS counts the WHOLE FILE including frontmatter and +# is a spec-conformance backstop; BODY_MAX_WORDS counts the body ONLY and is a +# quality gate. Likewise the 1024-character description limit above is the +# agentskills.io spec ceiling and stays exactly as it is — DESC_MAX_CHARS sits +# underneath it. +DESC_SUGGEST_CHARS = 250 +DESC_MAX_CHARS = 400 +BODY_SUGGEST_WORDS = 600 +BODY_MAX_WORDS = 900 + line_count = len(content.splitlines()) if line_count <= MAX_LINES: ok(f"SKILL.md line count {line_count} (limit: {MAX_LINES})") @@ -160,14 +1003,170 @@ if word_count <= MAX_WORDS: else: fail(f"SKILL.md word count {word_count} — exceeds {MAX_WORDS}-word limit (proxy for ~5,000 tokens)") -# Body unfilled placeholders body = content[body_start:] + +# --- ADR-0020: description budget ----------------------------------------- +if desc: + dlen = len(desc) + if dlen > DESC_MAX_CHARS: + fail(f"description is {dlen} chars — exceeds the {DESC_MAX_CHARS}-character " + f"ADR-0020 ceiling. It is preloaded into every session whether or not the " + f"skill is invoked. Keep a trigger clause, at most one capability clause, " + f"and a boundary clause; move capability enumeration, output-format detail, " + f"composition notes and implementation detail to the body or README.md") + elif dlen > DESC_SUGGEST_CHARS: + suggest(f"description is {dlen} chars — over the {DESC_SUGGEST_CHARS}-character " + f"ADR-0020 target (hard fail at {DESC_MAX_CHARS}). The SUGGESTION tier is " + f"what moves the corpus average; the FAIL tier only stops outliers") + else: + ok(f"description length {dlen} chars (ADR-0020 target: {DESC_SUGGEST_CHARS})") + +# --- ADR-0020: body budget ------------------------------------------------- +# Counts the BODY ONLY — everything after the closing --- of the frontmatter. +# This is a different measurement from MAX_WORDS above, which counts the whole +# file including frontmatter as a spec-conformance backstop. Both are reported. +body_word_count = len(body.split()) +if body_word_count > BODY_MAX_WORDS: + fail(f"SKILL.md body is {body_word_count} words — exceeds the {BODY_MAX_WORDS}-word " + f"ADR-0020 ceiling (body only; separate from the {MAX_WORDS}-word whole-file " + f"limit above). Move lookup tables, spec restatements, output schemas, templates " + f"and rationale prose to references/ behind an explicit " + f"\"If X, read `references/file.md`\" trigger. At two or more mutually exclusive " + f"flows, dispatch is mandatory: the body carries the dispatch table and the gates " + f"common to every branch, each flow gets its own self-contained references/ file") +elif body_word_count > BODY_SUGGEST_WORDS: + suggest(f"SKILL.md body is {body_word_count} words — over the {BODY_SUGGEST_WORDS}-word " + f"ADR-0020 target (hard fail at {BODY_MAX_WORDS})") +else: + ok(f"SKILL.md body word count {body_word_count} (ADR-0020 target: {BODY_SUGGEST_WORDS})") + +# --- Reference pointers must exist ----------------------------------------- +# FAIL, not SUGGESTION: a dispatch table naming a references/ file that is not +# on disk is a hard break, and until this check existed nothing in the +# gate/audit/vale stack noticed it — all three exited 0. +missing_refs = missing_reference_pointers(body, skill_dir) +for ref in missing_refs: + fail(f"SKILL.md body points at {ref}, which does not exist on disk — a dispatch " + f"table or \"read X\" trigger naming a missing file sends the agent nowhere") +if not missing_refs: + ok("all referenced references/ files exist") + +# --- Gotchas discipline ----------------------------------------------------- +# SUGGESTION on both counts: the measurement is deterministic, but whether a +# given gotcha earns its place in the body is the auditor's judgment. +gotchas = gotcha_stats(body) +if gotchas is not None: + gotcha_entries, gotcha_words = gotchas + if gotcha_entries > GOTCHA_MAX_ENTRIES: + suggest(f"Gotchas section has {gotcha_entries} entries — over the " + f"{GOTCHA_MAX_ENTRIES}-entry guideline. A list that long is usually a " + f"missing references/ file or a design problem written up as a warning") + if body_word_count and gotcha_words > body_word_count * GOTCHA_MAX_BODY_FRACTION: + suggest(f"Gotchas section is {gotcha_words} of {body_word_count} body words " + f"({round(100.0 * gotcha_words / body_word_count)}%) — over the " + f"{round(100.0 * GOTCHA_MAX_BODY_FRACTION)}% guideline. Move the durable " + f"parts to references/ and keep the section for live traps") + +# --- ADR-0020: boundary clause present ------------------------------------- +# SUGGESTION, not FAIL: detecting the absence is deterministic, but whether +# this particular skill warrants a boundary clause is judgment. Both accepted +# shapes count — the prose markers and the compressed `Not -> `. +if desc: + if has_boundary_clause(desc): + ok("description has a boundary clause") + else: + suggest("description has no boundary clause — add the prose form (\"Do not use " + "for X — use `y` instead\") or ADR-0020's compressed form (\"Not X -> y\") " + "so the router knows where NOT to send this skill") + +# --- ADR-0020: resolvable boundary targets --------------------------------- +# The resolution universe comes from the SKILL's own location: the authoring +# root above it (every sibling plugin in the monorepo), its own apm package, and +# the packages that package declares in apm.yml dependencies.apm. It is never +# derived from this script's own path, and — when an authoring root exists — it +# never reads a deployed .claude/ tree, so a fresh clone and a machine that has +# run `apm install` return the same verdict. See the shared resolver's header. +if desc: + routing_targets = boundary_targets(desc) + known = known_targets(skill_dir) if routing_targets else set() + if routing_targets and not known: + info(f"boundary-target resolution DID NOT RUN — no skill universe could be " + f"determined for this path (no authoring root above it, no apm package " + f"root, no declared apm dependencies, no deployed .claude/ or .agents/ " + f"tree). Unchecked target(s): {', '.join(routing_targets)}") + elif routing_targets: + # blocking vs reported: a target only earns a FAIL when it is written in + # route notation or its own sentence corroborates it by naming another + # target that resolves. See the shared resolver's CORROBORATION note. + unresolved, soft = unresolved_targets(desc, known) + for target in unresolved: + fail(f"description routes to '{target}', which resolves to no skill or agent " + f"in this monorepo, in this package, or in a package it declares in " + f"apm.yml dependencies.apm — a boundary clause naming a non-existent " + f"target sends the router nowhere") + for target in soft: + suggest(f"description routes to '{target}', which resolves to no skill or agent " + f"in this monorepo, in this package, or in a package it declares in " + f"apm.yml dependencies.apm — SUGGESTION rather than FAIL because nothing " + f"else in that sentence resolves, so it is equally likely to be a tool, a " + f"file format or an English compound. If it IS a route, write it as " + f"`/{target}` or `-> {target}` and it will be checked properly") + if not unresolved: + # Counts the targets that ACTUALLY resolve, not every target found: + # a confirm-only target (one used attributively — see the resolver's + # ATTRIBUTIVE USE note) is exempt from the failure above, so + # reporting it as resolved would be a false claim. + resolved = [t for t in routing_targets if normalize_target(t) in known] + ok(f"{len(resolved)} of {len(routing_targets)} boundary target(s) resolve: " + f"{', '.join(resolved) if resolved else '(none)'}") + +# Body unfilled placeholders fill_matches = PLACEHOLDER_RE.findall(body) if fill_matches: fail(f"SKILL.md body contains {len(fill_matches)} unfilled 'FILL IN:' placeholder(s)") else: ok("SKILL.md body has no unfilled placeholders") +# Interactive prompt heuristic. +# +# A line-initial `read` only blocks an agent when its stdin is the terminal. +# These forms never touch a TTY and are ordinary data plumbing, so flagging +# them is a false positive — one that has already cost two authors a +# contorted rewrite of working source: +# +# read -r MODE ROOT <<< "$WALK_OUTPUT" here-string +# read -r X <: " X` is interactive and + # must still fail. + unquoted = re.sub(r'"[^"]*"|\'[^\']*\'', '', line) + return '<' in unquoted or prev_line.rstrip().endswith('|') + +def interactive_reads(source): + hits = [] + prev_line = '' + for line in source.splitlines(): + stripped = line.strip() + if re.match(r'read(\s|$)', stripped): + if not stdin_redirected(line, prev_line): + hits.append(stripped) + elif re.match(r'input\(', stripped): + hits.append(stripped) + # Blank lines and comments cannot carry the pipe that feeds a + # following `read`, so they never displace the previous line. + if stripped and not stripped.startswith('#'): + prev_line = line + return hits + # Scripts checks scripts_dir = os.path.join(skill_dir, "scripts") if os.path.isdir(scripts_dir): @@ -175,12 +1174,19 @@ if os.path.isdir(scripts_dir): if os.path.isfile(os.path.join(scripts_dir, f)) and not f.endswith('.md')] for fname in scripts: fpath = os.path.join(scripts_dir, fname) - with open(fpath) as f: - sc = f.read() - # Interactive prompt heuristic - if re.search(r'^\s*(read\s|input\()', sc, re.MULTILINE): - fail(f"scripts/{fname}: may use interactive input (read/input detected)") - else: + try: + sc = read_text(fpath) + except EncodingError as exc: + # The executable-bit check below still runs — one unreadable byte + # must not silently drop a second, independent check. + sc = None + fail(f"scripts/{fname}: {exc} — it could not be scanned for " + f"interactive prompts") + interactive = interactive_reads(sc) if sc is not None else [] + if interactive: + fail(f"scripts/{fname}: may use interactive input " + f"(read/input from a terminal detected): {interactive[0]}") + elif sc is not None: ok(f"scripts/{fname}: no interactive prompts detected") # Executable bit if os.access(fpath, os.X_OK): @@ -190,8 +1196,17 @@ if os.path.isdir(scripts_dir): # Summary print() +for s in suggestions: + print(f"SUGGESTION {s}") +if suggestions: + print() if not failed: - print("All checks passed.") + if suggestions: + # Feeds skill-audit's Step 4 `PASS (N suggestions)` result line. A + # SUGGESTION never changes the exit code — only a FAIL does. + print(f"All checks passed ({len(suggestions)} suggestion(s)).") + else: + print("All checks passed.") sys.exit(0) else: print("One or more checks failed.") diff --git a/plugins/kyberforge/.apm/skills/skill-audit/tests/validate.bats b/plugins/kyberforge/.apm/skills/skill-audit/tests/validate.bats index 505477e..f63d799 100755 --- a/plugins/kyberforge/.apm/skills/skill-audit/tests/validate.bats +++ b/plugins/kyberforge/.apm/skills/skill-audit/tests/validate.bats @@ -8,7 +8,14 @@ setup() { SCRIPT="$(cd "$BATS_TEST_DIRNAME/../scripts" && pwd)/validate.sh" TMPDIR="$(mktemp -d)" - # Helper: create a minimal valid skill directory + # Helper: create a minimal valid skill directory. + # + # The description carries a boundary clause deliberately. ADR-0020's + # missing-boundary-clause SUGGESTION fires on any description without one, so + # a fixture that omits it is never "otherwise clean" — every test asserting + # SUGGESTION-freedom would be asserting the boundary check's absence instead + # of the thing it names. "anything else" is not hyphenated, so the clause adds + # a boundary marker without adding a routing target to resolve. make_valid_skill() { local dir="$1" local name @@ -17,7 +24,7 @@ setup() { cat > "$dir/SKILL.md" < characters that carries a boundary + # clause and names no routing target. The tests below measure the description + # LENGTH, so the clause has to be paid for out of the same budget rather than + # appended to it — hence the padding arithmetic instead of a fixed suffix. + desc_of_length() { + python3 - "$1" <<'PY' +import sys +n = int(sys.argv[1]) +prefix = 'Use when doing the thing. Do not use for anything else. ' +assert n >= len(prefix), 'requested description shorter than the boundary clause' +print(prefix + 'x' * (n - len(prefix))) +PY + } + + # Helper: create a skill directory with an exact description length and an + # exact body word count. is used verbatim; "word" + # tokens follow the frontmatter. Used by the ADR-0020 boundary tests. + make_sized_skill() { + local dir="$1" desc="$2" body_words="$3" + local name + name="$(basename "$dir")" + mkdir -p "$dir" + { + echo "---" + echo "name: $name" + echo "description: $desc" + echo "---" + echo "" + python3 -c "print(' '.join(['word'] * $body_words))" + } > "$dir/SKILL.md" + } + + # Helper: build a self-contained fixture plugin tree so the boundary-target + # resolver has a real authoring source to resolve against, independent of + # this repo's live skills. Echoes the subject skill's directory. + # + # /plugins/fixture-plugin/.apm/skills//SKILL.md + # /plugins/fixture-plugin/.apm/skills/fixture-sibling-skill/ + # /plugins/fixture-plugin/.apm/agents/fixture-sibling-agent.agent.md + make_fixture_tree() { + local root="$1" subject="$2" + local apm="$root/plugins/fixture-plugin/.apm" + mkdir -p "$apm/skills/$subject" "$apm/skills/fixture-sibling-skill" "$apm/agents" + touch "$apm/agents/fixture-sibling-agent.agent.md" + echo "$apm/skills/$subject" + } } teardown() { @@ -64,7 +118,7 @@ teardown() { assert_success } -@test "passes at exactly 1024-char description" { +@test "the 1024-char agentskills.io spec backstop is unchanged and separate from the ADR-0020 ceiling" { local skill="$TMPDIR/my-skill" local name name="$(basename "$skill")" @@ -82,7 +136,12 @@ description: $desc Do the thing. EOF run bash "$SCRIPT" "$skill" - assert_success + # Two independent gates on one value: the spec limit still PASSES at + # exactly 1024 (its own boundary is unmoved), while ADR-0020's 400-char + # ceiling FAILs. The run fails on the second, not the first. + assert_output --partial "description length 1024 chars (agentskills.io spec limit: 1024)" + assert_output --partial "400-character ADR-0020 ceiling" + assert_failure } @test "passes at exactly 500 lines" { @@ -170,6 +229,61 @@ EOF assert_failure } +@test "fails when a script reads a variable with no redirect" { + local skill="$TMPDIR/my-skill" + make_valid_skill "$skill" + printf '#!/usr/bin/env bash\nread -r ANSWER\n' > "$skill/scripts/helper.sh" + chmod +x "$skill/scripts/helper.sh" + run bash "$SCRIPT" "$skill" + assert_failure +} + +@test "fails when an interactive prompt string contains an angle bracket" { + local skill="$TMPDIR/my-skill" + make_valid_skill "$skill" + printf '#!/usr/bin/env bash\nread -p "enter : " NAME\n' > "$skill/scripts/helper.sh" + chmod +x "$skill/scripts/helper.sh" + run bash "$SCRIPT" "$skill" + assert_failure +} + +@test "passes when a script reads from a here-string" { + local skill="$TMPDIR/my-skill" + make_valid_skill "$skill" + printf '#!/usr/bin/env bash\nLINE="a b"\nread -r X Y <<< "$LINE"\n' \ + > "$skill/scripts/helper.sh" + chmod +x "$skill/scripts/helper.sh" + run bash "$SCRIPT" "$skill" + assert_success +} + +@test "passes when a script reads from a here-doc" { + local skill="$TMPDIR/my-skill" + make_valid_skill "$skill" + printf '#!/usr/bin/env bash\nread -r X < "$skill/scripts/helper.sh" + chmod +x "$skill/scripts/helper.sh" + run bash "$SCRIPT" "$skill" + assert_success +} + +@test "passes when a script reads from a file redirect" { + local skill="$TMPDIR/my-skill" + make_valid_skill "$skill" + printf '#!/usr/bin/env bash\nread -r LINE < "$1"\n' > "$skill/scripts/helper.sh" + chmod +x "$skill/scripts/helper.sh" + run bash "$SCRIPT" "$skill" + assert_success +} + +@test "passes when a script reads from a pipe continued onto the next line" { + local skill="$TMPDIR/my-skill" + make_valid_skill "$skill" + printf '#!/usr/bin/env bash\nprintf %%s "$1" |\n read -r X\n' > "$skill/scripts/helper.sh" + chmod +x "$skill/scripts/helper.sh" + run bash "$SCRIPT" "$skill" + assert_success +} + @test "fails when name contains consecutive hyphens" { local skill="$TMPDIR/my--skill" make_valid_skill "$skill" @@ -196,3 +310,261 @@ EOF run bash "$SCRIPT" assert_failure } + +# --------------------------------------------------------------------------- +# ADR-0020 — description budget (250 SUGGESTION / 400 FAIL) +# +# These sit UNDER the agentskills.io 1024-character spec backstop above, which +# is unchanged. Both ceilings are inclusive: exactly at the number passes that +# tier, one past it trips. +# --------------------------------------------------------------------------- + +@test "ADR-0020: description of exactly 250 chars raises no suggestion" { + local skill="$TMPDIR/my-skill" + make_sized_skill "$skill" "$(desc_of_length 250)" 10 + run bash "$SCRIPT" "$skill" + assert_success + refute_output --partial "SUGGESTION" +} + +@test "ADR-0020: description of 251 chars raises a SUGGESTION and still exits 0" { + local skill="$TMPDIR/my-skill" + make_sized_skill "$skill" "$(desc_of_length 251)" 10 + run bash "$SCRIPT" "$skill" + assert_success + assert_output --partial "SUGGESTION" + assert_output --partial "description is 251 chars" + assert_output --partial "All checks passed (1 suggestion(s))." +} + +@test "ADR-0020: description of exactly 400 chars is a SUGGESTION, not a FAIL" { + local skill="$TMPDIR/my-skill" + make_sized_skill "$skill" "$(desc_of_length 400)" 10 + run bash "$SCRIPT" "$skill" + assert_success + assert_output --partial "SUGGESTION" +} + +@test "ADR-0020: description of 401 chars FAILs and exits non-zero" { + local skill="$TMPDIR/my-skill" + make_sized_skill "$skill" "$(desc_of_length 401)" 10 + run bash "$SCRIPT" "$skill" + assert_failure + assert_output --partial "description is 401 chars" + assert_output --partial "400-character ADR-0020 ceiling" +} + +@test "ADR-0020: description length is measured after YAML folding is resolved" { + local skill="$TMPDIR/my-skill" + mkdir -p "$skill" + # A >-folded block scalar: 11 lines of 40 chars folded with 10 joining + # spaces = 450 characters. Measured off its raw `description: >` line it is + # 1 character and passes; measured as the folded VALUE it must FAIL. This + # is exactly the case a line-wise regex gets wrong. + { + echo "---" + echo "name: my-skill" + echo "description: >" + python3 -c "print('\n'.join([' ' + 'x' * 40] * 11))" + echo "---" + echo "" + echo "Do the thing." + } > "$skill/SKILL.md" + run bash "$SCRIPT" "$skill" + assert_failure + assert_output --partial "description is 450 chars" + assert_output --partial "400-character ADR-0020 ceiling" +} + +# --------------------------------------------------------------------------- +# ADR-0020 — body budget (600 SUGGESTION / 900 FAIL), body ONLY +# +# Distinct from the 2,770-word whole-file spec ceiling above, which counts +# frontmatter too and is unchanged. Do not unify them. +# --------------------------------------------------------------------------- + +@test "ADR-0020: body of exactly 600 words raises no suggestion" { + local skill="$TMPDIR/my-skill" + make_sized_skill "$skill" "A short valid description. Do not use for anything else." 600 + run bash "$SCRIPT" "$skill" + assert_success + refute_output --partial "SUGGESTION" +} + +@test "ADR-0020: body of 601 words raises a SUGGESTION and still exits 0" { + local skill="$TMPDIR/my-skill" + make_sized_skill "$skill" "A short valid description. Do not use for anything else." 601 + run bash "$SCRIPT" "$skill" + assert_success + assert_output --partial "body is 601 words" + assert_output --partial "All checks passed (1 suggestion(s))." +} + +@test "ADR-0020: body of exactly 900 words is a SUGGESTION, not a FAIL" { + local skill="$TMPDIR/my-skill" + make_sized_skill "$skill" "A short valid description. Do not use for anything else." 900 + run bash "$SCRIPT" "$skill" + assert_success + assert_output --partial "body is 900 words" +} + +@test "ADR-0020: body of 901 words FAILs and exits non-zero" { + local skill="$TMPDIR/my-skill" + make_sized_skill "$skill" "A short valid description. Do not use for anything else." 901 + run bash "$SCRIPT" "$skill" + assert_failure + assert_output --partial "body is 901 words" + assert_output --partial "900-word ADR-0020 ceiling" +} + +@test "ADR-0020: the body gate counts the body only — frontmatter words do not count toward it" { + local skill="$TMPDIR/my-skill" + # 895 body words plus a description long enough that the WHOLE FILE is well + # over 900 words. The body gate must stay silent; the 2,770-word whole-file + # ceiling is a separate measurement and is nowhere near tripping. + make_sized_skill "$skill" "$(python3 -c "print(' '.join(['w'] * 100))")" 895 + run bash "$SCRIPT" "$skill" + assert_success + refute_output --partial "900-word ADR-0020 ceiling" +} + +# --------------------------------------------------------------------------- +# ADR-0020 — resolvable boundary targets +# +# Resolved against the AUTHORING SOURCE (plugins/*/.apm/skills/ and +# plugins/*/.apm/agents/), never .claude/skills/, so the check works offline and +# before an apm install. Every fixture below builds its own plugin tree rather +# than leaning on this repo's live skills. +# --------------------------------------------------------------------------- + +@test "ADR-0020: a boundary target naming an existing sibling skill resolves" { + local skill + skill="$(make_fixture_tree "$TMPDIR/tree" "my-skill")" + make_sized_skill "$skill" "Use when doing the thing. Do not use for the other thing — use fixture-sibling-skill instead." 10 + run bash "$SCRIPT" "$skill" + assert_success + assert_output --partial "boundary target(s) resolve" +} + +@test "ADR-0020: a boundary target naming a non-existent skill FAILs when its sentence names one that resolves" { + local skill + skill="$(make_fixture_tree "$TMPDIR/tree" "my-skill")" + # `fixture-sibling-skill` is the corroborator: a prose-form target only earns + # a FAIL when its own sentence proves it is a routing sentence. See the + # shared resolver's CORROBORATION note, and the uncorroborated case below. + make_sized_skill "$skill" "Use when doing the thing. Do not use for the other thing — use fixture-sibling-skill or fixture-missing-skill instead." 10 + run bash "$SCRIPT" "$skill" + assert_failure + assert_output --partial "routes to 'fixture-missing-skill'" +} + +@test "ADR-0020: a LONE boundary target naming a non-existent skill is a SUGGESTION, not a FAIL" { + local skill + skill="$(make_fixture_tree "$TMPDIR/tree" "my-skill")" + # Same grammar as the case above and as "run \`pre-commit\` instead" — a + # route verb, a hyphenated name, terminal position. Nothing local separates a + # broken route from a tool name, so the target is named on every run but does + # not block: this gate ships with no baseline and no suppression mechanism. + make_sized_skill "$skill" "Use when doing the thing. Do not use for the other thing — use fixture-missing-skill instead." 10 + run bash "$SCRIPT" "$skill" + assert_success + assert_output --partial "SUGGESTION" + assert_output --partial "routes to 'fixture-missing-skill'" + refute_output --partial "FAIL description routes to" +} + +@test "ADR-0020: a boundary target naming an AGENT file resolves (agents are valid routing targets)" { + local skill + skill="$(make_fixture_tree "$TMPDIR/tree" "my-skill")" + make_sized_skill "$skill" "Use when doing the thing. Do not use when the caller is an agent — invoke fixture-sibling-agent instead." 10 + run bash "$SCRIPT" "$skill" + assert_success + assert_output --partial "boundary target(s) resolve" +} + +@test "ADR-0020: a /slash-command boundary target that does not resolve FAILs" { + local skill + skill="$(make_fixture_tree "$TMPDIR/tree" "my-skill")" + make_sized_skill "$skill" "Use when doing the thing. Do not use when improvements are wanted — use /fixture-missing-improve instead." 10 + run bash "$SCRIPT" "$skill" + assert_failure + assert_output --partial "routes to 'fixture-missing-improve'" +} + +@test "ADR-0020: a backticked name that does not resolve FAILs when its sentence names one that resolves" { + local skill + skill="$(make_fixture_tree "$TMPDIR/tree" "my-skill")" + make_sized_skill "$skill" "Use when doing the thing. Composes \`fixture-sibling-skill\` and \`fixture-missing-helper\` for the shared part." 10 + run bash "$SCRIPT" "$skill" + assert_failure + assert_output --partial "routes to 'fixture-missing-helper'" +} + +@test "ADR-0020: a /slash-command target is route NOTATION and FAILs on its own, uncorroborated" { + local skill + skill="$(make_fixture_tree "$TMPDIR/tree" "my-skill")" + # The escape hatch from the SUGGESTION tier: `/name` and `-> name` are never + # how prose cites a tool, so they are exempt from corroboration. An author + # who wants a route checked unconditionally writes one of those two forms. + make_sized_skill "$skill" "Use when doing the thing. Do not use for the other thing — use /fixture-missing-notation instead." 10 + run bash "$SCRIPT" "$skill" + assert_failure + assert_output --partial "routes to 'fixture-missing-notation'" +} + +@test "ADR-0020: a bare hyphenated word outside a boundary sentence is not read as a routing target" { + local skill + skill="$(make_fixture_tree "$TMPDIR/tree" "my-skill")" + # "run pre-commit hooks" is pc-run's real phrasing. A naive extractor reads + # it as a route to a non-existent `pre-commit` skill. + make_sized_skill "$skill" "Use when the user wants to run pre-commit hooks or install git hooks." 10 + run bash "$SCRIPT" "$skill" + assert_success + refute_output --partial "pre-commit" +} + +@test "ADR-0020: an arrow chain outside a boundary clause is not read as a routing target" { + local skill + skill="$(make_fixture_tree "$TMPDIR/tree" "my-skill")" + # diagnose's real process chain. Only ADR-0020's `Not -> ` + # form makes a bare arrow target a route. + make_sized_skill "$skill" "Reproduce → minimise → instrument → fix → regression-test. Use when a bug is reported." 10 + run bash "$SCRIPT" "$skill" + assert_success + refute_output --partial "regression-test" +} + +@test "ADR-0020: MCP tool names and capitalised tool names are not read as routing targets" { + local skill + skill="$(make_fixture_tree "$TMPDIR/tree" "my-skill")" + make_sized_skill "$skill" "Use when writing issues. Do not use for local files (use Read/Write/Edit) — that write goes through \`issue_write\`/\`pull_request_write\` instead." 10 + run bash "$SCRIPT" "$skill" + assert_success + refute_output --partial "routes to" +} + +@test "ADR-0020: ADR's compressed boundary form (Not -> ) is checked" { + local skill + skill="$(make_fixture_tree "$TMPDIR/tree" "my-skill")" + make_sized_skill "$skill" "Use when doing the thing. Not the other thing → fixture-missing-target." 10 + run bash "$SCRIPT" "$skill" + assert_failure + assert_output --partial "routes to 'fixture-missing-target'" +} + +@test "ADR-0020: the boundary check declines rather than false-FAILs when no authoring source is found" { + # Deliberately NOT built with make_fixture_tree: this skill sits in a bare + # temp directory with no plugins/*/.apm/ above it and no .git, so the resolver + # legitimately has no universe. That is a real path (a skill being drafted + # outside any repo), and the required behaviour is to DECLINE OUT LOUD rather + # than either false-FAIL or pass in silence — silence is what let a whole gate + # family go missing unnoticed. So the INFO text and the named unchecked target + # are both asserted, not just the absence of a failure. + local skill="$TMPDIR/orphan/my-skill" + make_sized_skill "$skill" "Use when doing the thing. Do not use for the other thing — use some-other-skill instead." 10 + run bash "$SCRIPT" "$skill" + assert_success + refute_output --partial "routes to" + assert_output --partial "boundary-target resolution DID NOT RUN" + assert_output --partial "Unchecked target(s): some-other-skill" +} diff --git a/plugins/kyberforge/.apm/skills/skill-author/README.md b/plugins/kyberforge/.apm/skills/skill-author/README.md index 590ea33..f169f44 100644 --- a/plugins/kyberforge/.apm/skills/skill-author/README.md +++ b/plugins/kyberforge/.apm/skills/skill-author/README.md @@ -6,6 +6,14 @@ Author and refine skills conforming to the [agentskills.io](https://agentskills. Routes to one of two flows based on context: if no skill directory exists at the target path, it scaffolds the directory from annotated templates, fills in `SKILL.md` and supporting files, and validates the result. If an existing skill directory and improvement signals are both present, it groups those signals by root cause and applies targeted edits, then re-validates. In both flows, bumps the skill's `metadata.version` when present (minor for create, patch for improve). +`SKILL.md` itself carries only the dispatch table, the invocation-axis decision, the contract gates and the shared close; each flow lives in its own self-contained reference file, per ADR-0020. + +## The contract it teaches + +Authored skills are held to the ADR-0020 context budget. A description carries a trigger clause, at most one capability clause, and a boundary clause of the form `Not -> ` whose target must resolve to a real skill or agent — 250 characters target, 400 hard ceiling. A body carries the decision procedure only — 600 words target, 900 hard ceiling, counting the body alone, which is a separate measurement from the 2,770-word / 500-line whole-file spec backstop. Skills with two or more mutually exclusive flows must dispatch. `references/contract.md` holds the full rules; `assets/templates/SKILL.md` encodes them as a fill-in skeleton. + +Before a description is written, the skill asks whether the target is model-invoked or hand-invoked. A hand-invoked skill sets `disable-model-invocation: true` and carries one plain human-facing sentence with no trigger list. + ## Before you start - Run `/grill-me` to resolve design decisions before creating a new skill @@ -14,7 +22,7 @@ Routes to one of two flows based on context: if no skill directory exists at the ## Placement -`scripts/new-skill.sh` resolves the mode automatically by walking up from the given path — see `SKILL.md` Step 1 for the full algorithm. +`scripts/new-skill.sh` resolves the mode automatically by walking up from the given path — see `references/create.md` Step 1 for the full algorithm. | Mode | Path | Chosen when | |------|------|-------------| @@ -36,10 +44,14 @@ If the destination resolves inside an APM package, read `references/deployment-m | `README.md` | Human-readable overview of the skill and its files | | `SKILL.md` | Skill instructions for agents | | `scripts/new-skill.sh` | Walks up from the given path to resolve package vs standalone mode, then copies annotated templates to the resolved destination | +| `references/create.md` | The create flow end to end — prerequisites, package-intent gate, scaffold, frontmatter, scripts, references, sources (loaded on demand) | +| `references/improve.md` | The improve flow end to end — signal verification, root-cause grouping, announcement, edits (loaded on demand) | +| `references/contract.md` | The ADR-0020 description and body contract, the Gotchas constraint, the two size gates, body patterns, and org-policy embedding (loaded on demand) | +| `references/retrofit.md` | Bringing a pre-ADR-0020 skill into contract — ordered cut procedure, the mutually-exclusive-flows test, reference-file conventions, the collateral checklist, and a worked description retrofit (loaded from the improve flow when a budget is exceeded) | | `references/deployment-modes.md` | APM package vs standalone differences and self-containment/cache-isolation rules (loaded on demand) | | `references/scripts.md` | Package runners, inline dependency patterns, and full script contract (loaded on demand) | | `references/sources.md` | Upstream research sources and which skill files each contributed to | -| `assets/templates/SKILL.md` | Annotated SKILL.md template | +| `assets/templates/SKILL.md` | Annotated SKILL.md template — emits an ADR-0020-compliant description and body skeleton | | `assets/templates/README.md` | Annotated README template for the new skill | | `assets/templates/scripts/README.md` | Placeholder for bundled scripts | | `assets/templates/references/README.md` | Placeholder for reference docs | diff --git a/plugins/kyberforge/.apm/skills/skill-author/SKILL.md b/plugins/kyberforge/.apm/skills/skill-author/SKILL.md index 9bbdb06..33228c5 100644 --- a/plugins/kyberforge/.apm/skills/skill-author/SKILL.md +++ b/plugins/kyberforge/.apm/skills/skill-author/SKILL.md @@ -1,14 +1,9 @@ --- name: skill-author description: > - Use when the user wants to create a new skill from scratch ("write a skill - for X", "build a skill that does Y", "create a SKILL.md for Z") or improve - an existing one ("improve this skill", "fix based on feedback", "apply these - audit findings", "update based on grill output"). Also use when the user provides inline feedback - about a skill's behavior and wants it applied, or when a grill session, eval - run, or audit has produced findings the user wants acted on — even if they - don't say "improve" explicitly. Do not use for read-only review — use - /skill-audit instead. Do not use to author agent definition files. + Use when the user wants to create a new skill from scratch, or apply audit + findings, grill output, eval results, or inline feedback to an existing one. + Not read-only review -> `skill-audit`. Not agent files -> `agent-author`. allowed-tools: Bash Read Write Edit metadata: category: factory @@ -24,283 +19,43 @@ metadata: ## Gotchas -- Patching per symptom is the default failure mode. Three eval failures may all trace to one missing instruction — always identify the root cause before editing. -- Do not create new scripts unless a signal explicitly calls for it. Writing scripts from scratch requires transcript analysis that is out of scope here; flag the opportunity as a suggestion instead. -- Never spawn a subagent to audit or recheck your own work during an authoring pass. Run `/skill-audit` yourself, inline, in the same context as the edits you just made. A *separate* independent recheck via a clean-context subagent is the `/forge` skill's outer-loop responsibility exclusively — delegating it inward here duplicates that layer and introduces a race: a stray self-spawned subagent can have its worktree torn down by concurrent cleanup, destroying an uncommitted draft before it was ever safe. +- The word gates are two measurements, not two tiers of one rule: the 2,770-word / 500-line spec backstop counts the whole file, Step 3's gate the body alone. Never unify them. +- Never spawn a subagent to audit or recheck your own work — run `/skill-audit` inline, in the same context as the edits. Clean-context recheck belongs to `/forge`'s outer loop, and a self-spawned subagent's worktree can be torn down by concurrent cleanup, destroying an uncommitted draft. +- Do not create new scripts unless a signal explicitly calls for it. Writing one from scratch requires out-of-scope transcript analysis — flag the opportunity as a suggestion instead. -## Route +## Step 1 — Dispatch -Determine which flow to follow before touching the filesystem: +| Condition | Flow | Reference | +|---|---|---| +| No skill directory at the target path | Create | `references/create.md` | +| Directory exists, at least one improvement signal present | Improve | `references/improve.md` | +| Directory exists, no signals | Stop and ask | — | -- **No skill directory at the target path** → follow **Creating a new skill** -- **Directory exists + at least one improvement signal present** → follow **Improving an existing skill** -- **Directory exists + no signals present** → ask: "No improvement signals found. Did you mean to create a new skill, or do you have feedback to apply?" +Signals: grill output, `/skill-audit` findings, inline feedback, eval results, session context describing what went wrong. With none, ask whether the user meant to create a new skill or has feedback to apply. -Signals include: grill session output, `/skill-audit` findings (PASS/FAIL punch list), inline user feedback, session context describing what went wrong. +Read only the reference matching the resolved flow — each is self-contained. If the target sits inside a git worktree, capture `git log --oneline -1` before touching the filesystem; Step 4 needs it. -**Before running the scaffold script**, judge whether the destination is meant to be inside an APM package — the script can't tell "no package here" apart from "package not scaffolded yet": +## Step 2 — Invocation axis -- Package intent but no `type:`-bearing `apm.yml` found at/above the destination (e.g. "add to my apm package", or a sibling `.apm/`/`apm.yml` exists nearby) → **stop**, tell the user to run `/apm-workflow configure` (`apm plugin init`, from inside the package directory) first, then retry. Don't fall through to standalone mode. -- Otherwise (a `~/`-rooted destination, or no package context implied) → run `scripts/new-skill.sh`; it resolves package vs. standalone automatically (see Step 1). +Decide before writing any description: model-invoked or hand-invoked? -## Creating a new skill +- **Hand-invoked** — the user types `/name` and no agent should route to it. Set `disable-model-invocation: true` and write one plain human-facing sentence: no trigger list, no boundary clause. Skip Step 3's description rules. +- **Model-invoked** — the default. -### Prerequisites +## Step 3 — Contract -Run `/grill-me` on the skill's design and research the target domain first. -Share those outputs in this conversation: grill context, research docs, examples, constraints. +Before writing or editing a description, or restructuring a body, read `references/contract.md` — the banned-content list, boundary form, include/exclude rubric and body patterns. -Design for one coherent user intent — skills too narrow force multiple loads per task; too broad are hard to activate precisely. +Gates `/skill-audit` enforces in both flows: -**Before touching the filesystem, verify you have:** -- [ ] A clear purpose — what specific task will this skill handle? -- [ ] Trigger scenarios — when should an agent activate it, including indirect cases? -- [ ] Skill name (kebab-case) and destination path -- [ ] Capture `git log --oneline -1` now, before touching the filesystem — Step 7 needs it to verify a real commit landed +- **Description** — a trigger clause, at most one capability clause, and a boundary clause shaped `Not -> ` whose target resolves to a real skill or agent. 250 characters SUGGESTION, 400 FAIL, value only. +- **Body** — decision procedure only: ordered steps, branches, gates, and which reference to load when. 600 words SUGGESTION, 900 FAIL, body only. At two or more mutually exclusive flows a dispatch table is mandatory and each flow gets its own self-contained `references/` file. +- **Gotchas** — each contradicting a reasonable default. A Gotcha paraphrasing a step below it is a FAIL; over five entries is a SUGGESTION only. -If any are missing, stop and ask the user before proceeding. +## Step 4 — Validate and close -**Requires `/skill-audit`** — used in Step 7 for final validation. Both skills ship in the kyberforge plugin and are co-installed. If `/skill-audit` is unavailable, stop and ask the user to install the kyberforge plugin before continuing. +Run `/skill-audit` on the resolved skill directory; resolve every FAIL before reporting done. It checks name-to-directory match, placeholders, both size budgets, boundary-target resolution and script hygiene — do not hand-check those. Hand-check the one thing it misses: an empty body reports `PASS SKILL.md body word count 0 (ADR-0020 target: 600)`, so confirm at least one non-empty section exists. -### Step 1 — Scaffold +With `metadata.version` present, bump the **minor** version on create (new skills start at `0.1.0`) and the **patch** version on improve. -Run the copy script with the skill name and a path inside or at the target: - -```bash -bash scripts/new-skill.sh -``` - -The script walks up from `` for a package boundary: an ancestor `apm.yml` with a top-level `type:` field (`instructions`/`skill`/`hybrid`/`prompts`) means **package mode** — scaffolds into `/.apm/skills//`, not under `` (a subdirectory of the package works fine as ``). A `type:`-less `apm.yml` is a marketplace-only manifest, skipped. Hitting `.git` or the filesystem root first means **standalone mode** — scaffolds directly into `//`, same as before. - -Examples: -```bash -# Package mode — packages/my-pkg/apm.yml already has `type: skill` -bash scripts/new-skill.sh my-tool packages/my-pkg/ - -# Standalone mode — no apm.yml/.git above ~/.agents/skills/ -bash scripts/new-skill.sh my-tool ~/.agents/skills/ -``` - -The script prints which mode it used and where the skill landed — read its output. - -In package mode, read `references/deployment-modes.md` before adding any file references to SKILL.md. - -### Step 2 — Update `apm.yml` includes (package mode only) - -Skip in standalone mode. In package mode, check the resolved package's `apm.yml`: if `includes:` is an explicit list (not `auto`), append `.apm/skills//` to it if not already present, preserving YAML formatting. If `includes: auto` or the field is absent, do nothing — `auto` already covers the new skill. Use Read/Edit directly on `apm.yml`; this isn't part of `scripts/new-skill.sh`. - -### Step 3 — Fill in SKILL.md - -Open the new skill's `SKILL.md` (the path Step 1 printed). Replace every `FILL IN:` placeholder. - -**Frontmatter** - -**`name`** — already set by the scaffold script. Must exactly match the directory name. Format: 1–64 characters, lowercase letters/numbers/hyphens only, no leading, trailing, or consecutive hyphens (`--`). - -**`description`** — carries the entire triggering burden. Rules: -- Imperative: "Use when..." not "This skill..." -- Focus on user intent, not implementation — describe what the user is trying to achieve, not the skill's internal mechanics -- Specific about capabilities ("parses and validates OpenAPI specs", not "helps with APIs") -- Include indirect triggers: "even if the user doesn't mention X explicitly" -- Add "Do not use when..." only if a near-miss skill exists that could steal activations -- Hard limit: 1024 characters — count before finalizing - -**Optional fields** — uncomment and fill in or remove entirely: -- `license` — include when distributing the skill externally -- `compatibility` — include if the skill requires specific tools, runtimes, or network access (max 500 characters) -- `metadata` — key-value map; use `author`, `version`, `category`; add `source_keys` now (see below) if research sources are in context -- `allowed-tools` — space-separated pre-approved tools; reduces permission prompts (experimental — support varies by client) - -**`metadata.source_keys`** — if research sources are in context, list the relevant slugs here as you write the body; don't defer this to Step 6. Agents that fill in source_keys late tend to omit it entirely. Example: -```yaml -metadata: - source_keys: - - my-source-slug - - another-slug -``` - -**Embedding org-specific policy** — if a skill encodes a rule sourced from an org convention file (e.g. `core/instructions/*.md`), inline that content directly into the skill (SKILL.md or a `references/` file) rather than pointing to the file's path. Plugins must be self-contained and portable — the org file may not exist wherever the plugin is installed, and in this repo such files are meant to be deleted once their content is fully embedded downstream. Tag the inlined content with a `source_keys` entry using the same `references/sources.md` schema as Step 6, noting in the `Research doc:` field that the source is an org convention rather than a plugin research corpus entry, so provenance survives after the source file is gone. - -**Body — include only what the agent lacks** - -Rename the placeholder section heading to one that fits the skill's structure — `## Step 1`, `## Workflow`, `## Instructions`, etc. - -Ask of every sentence: "Would the agent get this wrong without it?" Cut anything that answers "no." - -**Include:** -- Non-obvious sequences or ordering constraints — the agent may skip or reorder steps without this -- Domain conventions the agent cannot infer from general knowledge — this is the core value a skill adds -- One default per decision point, plus one escape hatch — never a menu; menus cause the agent to pause or pick arbitrarily -- Gotchas — facts that defy reasonable assumptions; the agent will get these wrong every time without them - -**Exclude:** -- Concepts the agent already knows (what JSON is, how HTTP works) — adds tokens without changing behavior -- Exhaustive option lists — pick a default; the agent doesn't benefit from choosing -- Steps the agent handles independently — over-specifying leads agents to follow unproductive paths -- Restatements of the description — it's already in context; repeating it wastes the token budget - -**Patterns** - -**Gotchas** — highest value; place near the top: -````markdown -## Gotchas -- --